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 · 5h 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/profile_test.go +38−38
| @@ -111,9 +111,9 @@ func (s *site) repo(t *testing.T, name, owner string, readableByAnyone bool, iss | |||
| 111 | 111 | func (s *site) serve(t *testing.T, as *identity.Account, path string) *httptest.ResponseRecorder { | |
| 112 | 112 | t.Helper() | |
| 113 | 113 | mux := http.NewServeMux() | |
| 114 | mux.HandleFunc("GET /u", s.web.PeoplePage) | ||
| 115 | mux.HandleFunc("GET /u/{$}", s.web.PeoplePage) | ||
| 116 | mux.HandleFunc("GET /u/{name}", s.web.ProfilePage) | ||
| 114 | mux.HandleFunc("GET /-/u", s.web.PeoplePage) | ||
| 115 | mux.HandleFunc("GET /-/u/{$}", s.web.PeoplePage) | ||
| 116 | mux.HandleFunc("GET /-/u/{name}", s.web.ProfilePage) | ||
| 117 | 117 | r := httptest.NewRequest("GET", path, nil) | |
| 118 | 118 | if as != nil { | |
| 119 | 119 | r = r.WithContext(context.WithValue(r.Context(), principalKey{}, | |
| @@ -140,7 +140,7 @@ func TestProfileCountsOnlyWhatTheViewerCanRead(t *testing.T) { | |||
| 140 | 140 | t.Parallel() | |
| 141 | 141 | s := newSite(t) | |
| 142 | 142 | ||
| 143 | anon := s.serve(t, nil, "/u/alice") | ||
| 143 | anon := s.serve(t, nil, "/-/u/alice") | ||
| 144 | 144 | if anon.Code != http.StatusOK { | |
| 145 | 145 | t.Fatalf("anonymous profile = %d: %s", anon.Code, anon.Body) | |
| 146 | 146 | } | |
| @@ -160,7 +160,7 @@ func TestProfileCountsOnlyWhatTheViewerCanRead(t *testing.T) { | |||
| 160 | 160 | t.Fatalf("the profile does not ask crawlers to leave it alone:\n%s", body) | |
| 161 | 161 | } | |
| 162 | 162 | ||
| 163 | signed := s.serve(t, s.account(t, "alice"), "/u/alice") | ||
| 163 | signed := s.serve(t, s.account(t, "alice"), "/-/u/alice") | ||
| 164 | 164 | if signed.Code != http.StatusOK { | |
| 165 | 165 | t.Fatalf("signed-in profile = %d", signed.Code) | |
| 166 | 166 | } | |
| @@ -172,7 +172,7 @@ func TestProfileCountsOnlyWhatTheViewerCanRead(t *testing.T) { | |||
| 172 | 172 | func TestProfileOfAnUnknownNameIsNotFound(t *testing.T) { | |
| 173 | 173 | t.Parallel() | |
| 174 | 174 | s := newSite(t) | |
| 175 | rw := s.serve(t, nil, "/u/nobody") | ||
| 175 | rw := s.serve(t, nil, "/-/u/nobody") | ||
| 176 | 176 | if rw.Code != http.StatusNotFound { | |
| 177 | 177 | t.Fatalf("unknown name = %d, want 404", rw.Code) | |
| 178 | 178 | } | |
| @@ -189,7 +189,7 @@ func TestRetiredProfileStillOpens(t *testing.T) { | |||
| 189 | 189 | if _, err := s.ident.Retire("bob", "alice"); err != nil { | |
| 190 | 190 | t.Fatal(err) | |
| 191 | 191 | } | |
| 192 | rw := s.serve(t, nil, "/u/bob") | ||
| 192 | rw := s.serve(t, nil, "/-/u/bob") | ||
| 193 | 193 | if rw.Code != http.StatusOK { | |
| 194 | 194 | t.Fatalf("retired profile = %d", rw.Code) | |
| 195 | 195 | } | |
| @@ -201,12 +201,12 @@ func TestRetiredProfileStillOpens(t *testing.T) { | |||
| 201 | 201 | func TestProfileFilterLinksAndHeading(t *testing.T) { | |
| 202 | 202 | t.Parallel() | |
| 203 | 203 | s := newSite(t) | |
| 204 | body := s.serve(t, nil, "/u/alice").Body.String() | ||
| 204 | body := s.serve(t, nil, "/-/u/alice").Body.String() | ||
| 205 | 205 | for _, want := range []string{ | |
| 206 | `href="/u/alice?type=issues"`, | ||
| 207 | `href="/u/alice?type=changes"`, | ||
| 208 | `href="/u/alice?type=reviews"`, | ||
| 209 | `href="/u/alice?type=comments"`, | ||
| 206 | `href="/-/u/alice?type=issues"`, | ||
| 207 | `href="/-/u/alice?type=changes"`, | ||
| 208 | `href="/-/u/alice?type=reviews"`, | ||
| 209 | `href="/-/u/alice?type=comments"`, | ||
| 210 | 210 | "Issues opened", | |
| 211 | 211 | "Changes reviewed", | |
| 212 | 212 | } { | |
| @@ -216,8 +216,8 @@ func TestProfileFilterLinksAndHeading(t *testing.T) { | |||
| 216 | 216 | } | |
| 217 | 217 | // The filtered page says which filter is on, so a bookmarked URL is not a | |
| 218 | 218 | // mystery. | |
| 219 | filtered := s.serve(t, nil, "/u/alice?type=reviews").Body.String() | ||
| 220 | if !strings.Contains(filtered, `class="chip active" href="/u/alice?type=reviews"`) { | ||
| 219 | filtered := s.serve(t, nil, "/-/u/alice?type=reviews").Body.String() | ||
| 220 | if !strings.Contains(filtered, `class="chip active" href="/-/u/alice?type=reviews"`) { | ||
| 221 | 221 | t.Fatalf("the reviews filter is not marked active:\n%s", filtered) | |
| 222 | 222 | } | |
| 223 | 223 | } | |
| @@ -226,7 +226,7 @@ func TestPeopleListsEveryAccountAndFilters(t *testing.T) { | |||
| 226 | 226 | t.Parallel() | |
| 227 | 227 | s := newSite(t) | |
| 228 | 228 | ||
| 229 | all := s.serve(t, nil, "/u") | ||
| 229 | all := s.serve(t, nil, "/-/u") | ||
| 230 | 230 | if all.Code != http.StatusOK { | |
| 231 | 231 | t.Fatalf("directory = %d: %s", all.Code, all.Body) | |
| 232 | 232 | } | |
| @@ -241,12 +241,12 @@ func TestPeopleListsEveryAccountAndFilters(t *testing.T) { | |||
| 241 | 241 | t.Fatal("the directory does not ask crawlers to leave it alone") | |
| 242 | 242 | } | |
| 243 | 243 | ||
| 244 | filtered := s.serve(t, nil, "/u?q=ali").Body.String() | ||
| 244 | filtered := s.serve(t, nil, "/-/u?q=ali").Body.String() | ||
| 245 | 245 | if !strings.Contains(filtered, "alice") || strings.Contains(filtered, "adam") { | |
| 246 | 246 | t.Fatalf("the name filter returned the wrong people:\n%s", filtered) | |
| 247 | 247 | } | |
| 248 | 248 | ||
| 249 | empty := s.serve(t, nil, "/u?q=nobody").Body.String() | ||
| 249 | empty := s.serve(t, nil, "/-/u?q=nobody").Body.String() | ||
| 250 | 250 | if !strings.Contains(empty, "No one matches") { | |
| 251 | 251 | t.Fatalf("an empty search has no empty state:\n%s", empty) | |
| 252 | 252 | } | |
| @@ -261,17 +261,17 @@ func TestRetiredAccountsAreMarkedAndFilterable(t *testing.T) { | |||
| 261 | 261 | t.Fatal(err) | |
| 262 | 262 | } | |
| 263 | 263 | ||
| 264 | everyone := s.serve(t, nil, "/u").Body.String() | ||
| 265 | if !strings.Contains(everyone, `href="/u/bob"`) || !strings.Contains(everyone, `class="state retired"`) { | ||
| 264 | everyone := s.serve(t, nil, "/-/u").Body.String() | ||
| 265 | if !strings.Contains(everyone, `href="/-/u/bob"`) || !strings.Contains(everyone, `class="state retired"`) { | ||
| 266 | 266 | t.Fatalf("the retired account is not marked in the table:\n%s", everyone) | |
| 267 | 267 | } | |
| 268 | 268 | ||
| 269 | active := s.serve(t, nil, "/u?status=active").Body.String() | ||
| 270 | if strings.Contains(active, `href="/u/bob"`) { | ||
| 269 | active := s.serve(t, nil, "/-/u?status=active").Body.String() | ||
| 270 | if strings.Contains(active, `href="/-/u/bob"`) { | ||
| 271 | 271 | t.Fatalf("the active filter kept a retired account:\n%s", active) | |
| 272 | 272 | } | |
| 273 | retired := s.serve(t, nil, "/u?status=retired").Body.String() | ||
| 274 | if !strings.Contains(retired, `href="/u/bob"`) || strings.Contains(retired, `href="/u/alice"`) { | ||
| 273 | retired := s.serve(t, nil, "/-/u?status=retired").Body.String() | ||
| 274 | if !strings.Contains(retired, `href="/-/u/bob"`) || strings.Contains(retired, `href="/-/u/alice"`) { | ||
| 275 | 275 | t.Fatalf("the retired filter returned the wrong accounts:\n%s", retired) | |
| 276 | 276 | } | |
| 277 | 277 | } | |
| @@ -284,10 +284,10 @@ func TestDirectorySortsByItsHeaders(t *testing.T) { | |||
| 284 | 284 | ||
| 285 | 285 | // Default is the name, A to Z. alice opened the fixture's issues; adam and bob | |
| 286 | 286 | // have none, so a count order is a different question. | |
| 287 | body := s.serve(t, nil, "/u").Body.String() | ||
| 288 | adam := strings.Index(body, `href="/u/adam"`) | ||
| 289 | alice := strings.Index(body, `href="/u/alice"`) | ||
| 290 | bob := strings.Index(body, `href="/u/bob"`) | ||
| 287 | body := s.serve(t, nil, "/-/u").Body.String() | ||
| 288 | adam := strings.Index(body, `href="/-/u/adam"`) | ||
| 289 | alice := strings.Index(body, `href="/-/u/alice"`) | ||
| 290 | bob := strings.Index(body, `href="/-/u/bob"`) | ||
| 291 | 291 | if adam < 0 || alice < 0 || bob < 0 || !(adam < alice && alice < bob) { | |
| 292 | 292 | t.Fatalf("the default order is not A to Z: adam=%d alice=%d bob=%d", adam, alice, bob) | |
| 293 | 293 | } | |
| @@ -299,8 +299,8 @@ func TestDirectorySortsByItsHeaders(t *testing.T) { | |||
| 299 | 299 | t.Fatalf("the Issues header does not ask for the largest first:\n%s", body) | |
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | ordered := s.serve(t, nil, "/u?sort=issues&dir=desc").Body.String() | ||
| 303 | if strings.Index(ordered, `href="/u/alice"`) > strings.Index(ordered, `href="/u/adam"`) { | ||
| 302 | ordered := s.serve(t, nil, "/-/u?sort=issues&dir=desc").Body.String() | ||
| 303 | if strings.Index(ordered, `href="/-/u/alice"`) > strings.Index(ordered, `href="/-/u/adam"`) { | ||
| 304 | 304 | t.Fatalf("the issues order does not put the only author first:\n%s", ordered) | |
| 305 | 305 | } | |
| 306 | 306 | if !strings.Contains(ordered, `aria-sort="descending"`) { | |
| @@ -422,22 +422,22 @@ func TestParseProfileFilter(t *testing.T) { | |||
| 422 | 422 | ||
| 423 | 423 | func TestProfileAndPeopleURLsKeepTheFilter(t *testing.T) { | |
| 424 | 424 | t.Parallel() | |
| 425 | if got := profileURL("alice", "")(1); got != "/u/alice" { | ||
| 425 | if got := profileURL("alice", "")(1); got != "/-/u/alice" { | ||
| 426 | 426 | t.Fatalf("first page = %q", got) | |
| 427 | 427 | } | |
| 428 | 428 | // A filtered page keeps the filter the reader chose, in the page's spelling. | |
| 429 | if got := profileURL("alice", "reviews")(2); got != "/u/alice?page=2&type=reviews" { | ||
| 429 | if got := profileURL("alice", "reviews")(2); got != "/-/u/alice?page=2&type=reviews" { | ||
| 430 | 430 | t.Fatalf("filtered page = %q", got) | |
| 431 | 431 | } | |
| 432 | 432 | // The default directory URL stays short. | |
| 433 | if got := peopleURL("", "", peopleSort{key: sortName}, 1); got != "/u" { | ||
| 433 | if got := peopleURL("", "", peopleSort{key: sortName}, 1); got != "/-/u" { | ||
| 434 | 434 | t.Fatalf("directory first page = %q", got) | |
| 435 | 435 | } | |
| 436 | if got := peopleURL("ali", "", peopleSort{key: sortName}, 2); got != "/u?page=2&q=ali" { | ||
| 436 | if got := peopleURL("ali", "", peopleSort{key: sortName}, 2); got != "/-/u?page=2&q=ali" { | ||
| 437 | 437 | t.Fatalf("searched page = %q", got) | |
| 438 | 438 | } | |
| 439 | 439 | // The search, the status and the order all survive a page link. | |
| 440 | if got := peopleURL("a", "active", peopleSort{key: sortReviews, desc: true}, 2); got != "/u?dir=desc&page=2&q=a&sort=reviews&status=active" { | ||
| 440 | if got := peopleURL("a", "active", peopleSort{key: sortReviews, desc: true}, 2); got != "/-/u?dir=desc&page=2&q=a&sort=reviews&status=active" { | ||
| 441 | 441 | t.Fatalf("filtered and sorted page = %q", got) | |
| 442 | 442 | } | |
| 443 | 443 | } | |
| @@ -451,7 +451,7 @@ func TestUserRefLinksOnlyAccounts(t *testing.T) { | |||
| 451 | 451 | w := s.web | |
| 452 | 452 | ||
| 453 | 453 | known := w.userRef("alice") | |
| 454 | if !known.Known || known.Href != "/u/alice" || known.Name != "alice" { | ||
| 454 | if !known.Known || known.Href != "/-/u/alice" || known.Name != "alice" { | ||
| 455 | 455 | t.Fatalf("known account = %+v", known) | |
| 456 | 456 | } | |
| 457 | 457 | unknown := w.userRef("someone-who-never-registered") | |
| @@ -474,15 +474,15 @@ func TestDirectoryPaginates(t *testing.T) { | |||
| 474 | 474 | } | |
| 475 | 475 | } | |
| 476 | 476 | ||
| 477 | first := s.serve(t, nil, "/u").Body.String() | ||
| 477 | first := s.serve(t, nil, "/-/u").Body.String() | ||
| 478 | 478 | if got := strings.Count(first, `class="people-name"`); got != peoplePage { | |
| 479 | 479 | t.Fatalf("first page shows %d rows, want %d", got, peoplePage) | |
| 480 | 480 | } | |
| 481 | if !strings.Contains(first, `href="/u?page=2"`) { | ||
| 481 | if !strings.Contains(first, `href="/-/u?page=2"`) { | ||
| 482 | 482 | t.Fatalf("the first page has no link to the second:\n%s", first) | |
| 483 | 483 | } | |
| 484 | 484 | ||
| 485 | second := s.serve(t, nil, "/u?sort=joined&dir=desc&page=2").Body.String() | ||
| 485 | second := s.serve(t, nil, "/-/u?sort=joined&dir=desc&page=2").Body.String() | ||
| 486 | 486 | want := 3 + peoplePage + 5 - peoplePage // the fixture's accounts plus the extra ones | |
| 487 | 487 | if got := strings.Count(second, `class="people-name"`); got != want { | |
| 488 | 488 | t.Fatalf("second page shows %d rows, want %d", got, want) | |