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/seo_test.go +21−21
| @@ -26,11 +26,11 @@ func TestRobotsTxtDescribesThePublicSurface(t *testing.T) { | |||
| 26 | 26 | for _, want := range []string{ | |
| 27 | 27 | "User-agent: *", | |
| 28 | 28 | "Allow: /", | |
| 29 | "Disallow: /admin", | ||
| 30 | "Disallow: /account", | ||
| 31 | "Disallow: /r/*/settings", | ||
| 32 | "Disallow: /r/*/issues/new", | ||
| 33 | "Disallow: /git/", | ||
| 29 | "Disallow: /-/admin", | ||
| 30 | "Disallow: /-/account", | ||
| 31 | "Disallow: /*/settings", | ||
| 32 | "Disallow: /*/issues/new", | ||
| 33 | "Disallow: /*.git/", | ||
| 34 | 34 | "Sitemap: " + f.http.URL + "/sitemap.xml", | |
| 35 | 35 | } { | |
| 36 | 36 | if !strings.Contains(body, want) { | |
| @@ -39,7 +39,7 @@ func TestRobotsTxtDescribesThePublicSurface(t *testing.T) { | |||
| 39 | 39 | } | |
| 40 | 40 | // Profiles are noindex pages, not disallowed ones: a crawler has to be able | |
| 41 | 41 | // to fetch them to see the request. | |
| 42 | for _, unwanted := range []string{"Disallow: /u\n", "Disallow: /u/"} { | ||
| 42 | for _, unwanted := range []string{"Disallow: /-/u\n", "Disallow: /-/u/"} { | ||
| 43 | 43 | if strings.Contains(body, unwanted) { | |
| 44 | 44 | t.Fatalf("robots.txt hides a noindex page with %q:\n%s", unwanted, body) | |
| 45 | 45 | } | |
| @@ -69,7 +69,7 @@ func TestSitemapListsPublicRepositoriesAndPages(t *testing.T) { | |||
| 69 | 69 | for _, want := range []string{ | |
| 70 | 70 | `<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`, | |
| 71 | 71 | "<loc>" + f.http.URL + "/sitemap-pages.xml</loc>", | |
| 72 | "<loc>" + f.http.URL + "/r/demo/sitemap.xml</loc>", | ||
| 72 | "<loc>" + f.http.URL + "/demo/sitemap.xml</loc>", | ||
| 73 | 73 | } { | |
| 74 | 74 | if !strings.Contains(body, want) { | |
| 75 | 75 | t.Fatalf("sitemap index is missing %q:\n%s", want, body) | |
| @@ -85,10 +85,10 @@ func TestSitemapListsPublicRepositoriesAndPages(t *testing.T) { | |||
| 85 | 85 | if _, signedIn := f.browserGet(t, "/sitemap.xml", cookie); strings.Contains(signedIn, "secret") { | |
| 86 | 86 | t.Fatalf("a signed-in sitemap names a private repository:\n%s", signedIn) | |
| 87 | 87 | } | |
| 88 | if code, _ := f.get("/r/secret/sitemap.xml"); code != http.StatusNotFound { | ||
| 88 | if code, _ := f.get("/secret/sitemap.xml"); code != http.StatusNotFound { | ||
| 89 | 89 | t.Fatalf("private repository sitemap = %d, want 404", code) | |
| 90 | 90 | } | |
| 91 | resp, _ = f.browserGet(t, "/r/secret/sitemap.xml", cookie) | ||
| 91 | resp, _ = f.browserGet(t, "/secret/sitemap.xml", cookie) | ||
| 92 | 92 | if resp.StatusCode != http.StatusNotFound { | |
| 93 | 93 | t.Fatalf("signed-in private repository sitemap = %d, want 404", resp.StatusCode) | |
| 94 | 94 | } | |
| @@ -96,7 +96,7 @@ func TestSitemapListsPublicRepositoriesAndPages(t *testing.T) { | |||
| 96 | 96 | _, body = f.browserGet(t, "/sitemap-pages.xml", "") | |
| 97 | 97 | for _, want := range []string{ | |
| 98 | 98 | "<loc>" + f.http.URL + "/</loc>", | |
| 99 | "<loc>" + f.http.URL + "/activity</loc>", | ||
| 99 | "<loc>" + f.http.URL + "/-/activity</loc>", | ||
| 100 | 100 | } { | |
| 101 | 101 | if !strings.Contains(body, want) { | |
| 102 | 102 | t.Fatalf("page sitemap is missing %q:\n%s", want, body) | |
| @@ -132,23 +132,23 @@ func TestRepoSitemapListsIssuesChangesAndReleases(t *testing.T) { | |||
| 132 | 132 | t.Fatalf("metadata push: %v", err) | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | resp, body := f.browserGet(t, "/r/"+f.repo+"/sitemap.xml", "") | ||
| 135 | resp, body := f.browserGet(t, "/"+f.repo+"/sitemap.xml", "") | ||
| 136 | 136 | if resp.StatusCode != http.StatusOK { | |
| 137 | 137 | t.Fatalf("repository sitemap = %d\n%s", resp.StatusCode, body) | |
| 138 | 138 | } | |
| 139 | 139 | base := f.http.URL | |
| 140 | 140 | for _, want := range []string{ | |
| 141 | 141 | `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`, | |
| 142 | "<loc>" + base + "/r/demo</loc>", | ||
| 143 | "<loc>" + base + "/r/demo/activity</loc>", | ||
| 144 | "<loc>" + base + "/r/demo/code</loc>", | ||
| 145 | "<loc>" + base + "/r/demo/issues</loc>", | ||
| 146 | "<loc>" + base + "/r/demo/issues/" + issueID + "</loc>", | ||
| 147 | "<loc>" + base + "/r/demo/changes</loc>", | ||
| 148 | "<loc>" + base + "/r/demo/changes/" + changeID + "</loc>", | ||
| 149 | "<loc>" + base + "/r/demo/releases</loc>", | ||
| 150 | "<loc>" + base + "/r/demo/releases/v1.0.0</loc>", | ||
| 151 | "<loc>" + base + "/r/demo/commits/main</loc>", | ||
| 142 | "<loc>" + base + "/demo</loc>", | ||
| 143 | "<loc>" + base + "/demo/activity</loc>", | ||
| 144 | "<loc>" + base + "/demo/code</loc>", | ||
| 145 | "<loc>" + base + "/demo/issues</loc>", | ||
| 146 | "<loc>" + base + "/demo/issues/" + issueID + "</loc>", | ||
| 147 | "<loc>" + base + "/demo/changes</loc>", | ||
| 148 | "<loc>" + base + "/demo/changes/" + changeID + "</loc>", | ||
| 149 | "<loc>" + base + "/demo/releases</loc>", | ||
| 150 | "<loc>" + base + "/demo/releases/v1.0.0</loc>", | ||
| 151 | "<loc>" + base + "/demo/commits/main</loc>", | ||
| 152 | 152 | "<lastmod>", | |
| 153 | 153 | } { | |
| 154 | 154 | if !strings.Contains(body, want) { | |