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/auth.go +13−9
| @@ -199,6 +199,10 @@ func (w *Web) recordPageView(r *http.Request, sw *statusWriter) { | |||
| 199 | 199 | if !strings.HasPrefix(sw.Header().Get("Content-Type"), "text/html") { | |
| 200 | 200 | return | |
| 201 | 201 | } | |
| 202 | // A module discovery probe is answered with HTML but is not a page. | ||
| 203 | if r.URL.Query().Get("go-get") != "" { | ||
| 204 | return | ||
| 205 | } | ||
| 202 | 206 | account := "" | |
| 203 | 207 | if pr := w.principal(r); pr != nil { | |
| 204 | 208 | account = pr.account.Name | |
| @@ -212,7 +216,7 @@ func (w *Web) recordPageView(r *http.Request, sw *statusWriter) { | |||
| 212 | 216 | // pages are read by the same few people watching the instance, so counting them | |
| 213 | 217 | // would measure administration rather than readership. | |
| 214 | 218 | func adminPath(path string) bool { | |
| 215 | return path == "/admin" || path == "/status" || path == "/users" || strings.HasPrefix(path, "/users/") | ||
| 219 | return path == "/-/admin" || path == "/-/status" || path == "/-/users" || strings.HasPrefix(path, "/-/users/") | ||
| 216 | 220 | } | |
| 217 | 221 | ||
| 218 | 222 | // sessionFromCookie returns the first valid session cookie and the name it came | |
| @@ -444,25 +448,25 @@ func (w *Web) page(r *http.Request, title string) Page { | |||
| 444 | 448 | v = v[:12] | |
| 445 | 449 | } | |
| 446 | 450 | if len(snap.CSS) > 0 { | |
| 447 | p.InstanceCSS = "/static/custom/profile/custom.css?v=" + v | ||
| 451 | p.InstanceCSS = "/-/static/custom/profile/custom.css?v=" + v | ||
| 448 | 452 | } | |
| 449 | 453 | if len(snap.Favicon) > 0 { | |
| 450 | p.InstanceFavicon = "/static/custom/profile/favicon.svg?v=" + v | ||
| 454 | p.InstanceFavicon = "/-/static/custom/profile/favicon.svg?v=" + v | ||
| 451 | 455 | } | |
| 452 | 456 | for _, t := range snap.Themes { | |
| 453 | 457 | p.InstanceThemes = append(p.InstanceThemes, instanceThemeView{ | |
| 454 | 458 | ID: t.ID, | |
| 455 | 459 | Name: t.Name, | |
| 456 | URL: "/static/custom/themes/" + t.ID + ".css?v=" + v, | ||
| 460 | URL: "/-/static/custom/themes/" + t.ID + ".css?v=" + v, | ||
| 457 | 461 | }) | |
| 458 | 462 | } | |
| 459 | 463 | } | |
| 460 | 464 | } | |
| 461 | 465 | if w.Repos != nil { | |
| 462 | // `{name}` is a repository only under /r/. A profile (/u/{name}) and the | ||
| 463 | // admin account page (/users/{name}) use the same path variable for an | ||
| 464 | // account, and neither should cost a failed repository lookup. | ||
| 465 | if name := r.PathValue("name"); name != "" && strings.HasPrefix(r.URL.Path, "/r/") { | ||
| 466 | // A path outside /-/ names a repository in its first segment. A profile | ||
| 467 | // (/-/u/{name}) and the admin account page (/-/users/{name}) share the | ||
| 468 | // `{name}` wildcard but are the forge's own pages, not repositories. | ||
| 469 | if name := r.PathValue("name"); name != "" && !strings.HasPrefix(r.URL.Path, "/-/") { | ||
| 466 | 470 | p.RepoName = name | |
| 467 | 471 | if m, err := w.repo(p.memo, name); err == nil { | |
| 468 | 472 | p.CloneURL = w.cloneURL(r, name) | |
| @@ -594,7 +598,7 @@ func (w *Web) requireHTMLUser(rw http.ResponseWriter, r *http.Request) (*princip | |||
| 594 | 598 | pr := w.principal(r) | |
| 595 | 599 | if pr == nil { | |
| 596 | 600 | next := url.QueryEscape(r.URL.RequestURI()) | |
| 597 | http.Redirect(rw, r, "/login?next="+next, http.StatusSeeOther) | ||
| 601 | http.Redirect(rw, r, "/-/login?next="+next, http.StatusSeeOther) | ||
| 598 | 602 | return nil, false | |
| 599 | 603 | } | |
| 600 | 604 | return pr, true | |