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/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 · 3h 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/admin_users_test.go +40−40

@@ -19,44 +19,44 @@ func TestAdminUsersLifecycle(t *testing.T) {
19 19
20 20 // The admin tab creates accounts; it does not list the roster, which is now
21 21 // the paginated users directory.
22 resp, body := f.browserGet(t, "/admin?tab=accounts", cookie)
22 resp, body := f.browserGet(t, "/-/admin?tab=accounts", cookie)
23 23 if resp.StatusCode != 200 || !strings.Contains(body, "Invite a person") ||
24 strings.Contains(body, `<a href="/users/keenan">Manage</a>`) {
24 strings.Contains(body, `<a href="/-/users/keenan">Manage</a>`) {
25 25 t.Fatalf("accounts tab = %d:\n%s", resp.StatusCode, body)
26 26 }
27 if !strings.Contains(body, `href="/admin?tab=accounts" aria-current="page"`) {
27 if !strings.Contains(body, `href="/-/admin?tab=accounts" aria-current="page"`) {
28 28 t.Fatalf("accounts tab is missing the active top-nav link:\n%s", body)
29 29 }
30 resp, body = f.browserGet(t, "/u", cookie)
30 resp, body = f.browserGet(t, "/-/u", cookie)
31 31 if resp.StatusCode != 200 ||
32 !strings.Contains(body, `<a class="people-name" href="/u/keenan">keenan</a>`) ||
33 !strings.Contains(body, `<a href="/users/keenan">Manage</a>`) {
32 !strings.Contains(body, `<a class="people-name" href="/-/u/keenan">keenan</a>`) ||
33 !strings.Contains(body, `<a href="/-/users/keenan">Manage</a>`) {
34 34 t.Fatalf("users directory = %d:\n%s", resp.StatusCode, body)
35 35 }
36 if _, home := f.browserGet(t, "/", cookie); !strings.Contains(home, `href="/admin"`) {
36 if _, home := f.browserGet(t, "/", cookie); !strings.Contains(home, `href="/-/admin"`) {
37 37 t.Fatal("the top navigation does not link to Users for an instance admin")
38 38 }
39 39
40 40 create := url.Values{
41 41 "csrf": {sess.CSRF}, "name": {"newbie"}, "display": {"New Bie"}, "role": {"contribute"},
42 42 }
43 resp, body = f.browserPost(t, "/users", cookie, create)
43 resp, body = f.browserPost(t, "/-/users", cookie, create)
44 44 if resp.StatusCode != 200 || !strings.Contains(body, "Enrollment link") || !strings.Contains(body, "newbie") {
45 45 t.Fatalf("create user = %d:\n%s", resp.StatusCode, body)
46 46 }
47 47 if resp.Header.Get("Cache-Control") != "no-store" {
48 48 t.Fatalf("enrollment page cache header = %q", resp.Header.Get("Cache-Control"))
49 49 }
50 idx := strings.Index(body, "/invite#")
50 idx := strings.Index(body, "/-/invite#")
51 51 if idx < 0 {
52 52 t.Fatalf("enrollment page has no link:\n%s", body)
53 53 }
54 rest := body[idx+len("/invite#"):]
54 rest := body[idx+len("/-/invite#"):]
55 55 token := rest[:strings.IndexAny(rest, "<\" ")]
56 56 if token == "" {
57 57 t.Fatal("enrollment link carries no token")
58 58 }
59 if resp, page := f.browserGet(t, "/invite", ""); resp.StatusCode != 200 || !strings.Contains(page, "Create a passkey") {
59 if resp, page := f.browserGet(t, "/-/invite", ""); resp.StatusCode != 200 || !strings.Contains(page, "Create a passkey") {
60 60 t.Fatalf("enrollment page = %d:\n%s", resp.StatusCode, page)
61 61 }
62 62
@@ -71,16 +71,16 @@ func TestAdminUsersLifecycle(t *testing.T) {
71 71 form := url.Values{
72 72 "csrf": {sess.CSRF}, "name": {"newbie"}, "display": {""},
73 73 }
74 if resp, page := f.browserPost(t, "/users", cookie, form); resp.StatusCode != 400 || !strings.Contains(page, "belongs") {
74 if resp, page := f.browserPost(t, "/-/users", cookie, form); resp.StatusCode != 400 || !strings.Contains(page, "belongs") {
75 75 t.Fatalf("duplicate name = %d:\n%s", resp.StatusCode, page)
76 76 }
77 77
78 resp, body = f.browserGet(t, "/users/"+acct.Name, cookie)
78 resp, body = f.browserGet(t, "/-/users/"+acct.Name, cookie)
79 79 if resp.StatusCode != 200 || !strings.Contains(body, "No SSH keys") || !strings.Contains(body, "No browser sessions") {
80 80 t.Fatalf("account page = %d:\n%s", resp.StatusCode, body)
81 81 }
82 82
83 resp, _ = f.browserPost(t, "/users/"+acct.Name+"/role", cookie, url.Values{
83 resp, _ = f.browserPost(t, "/-/users/"+acct.Name+"/role", cookie, url.Values{
84 84 "csrf": {sess.CSRF}, "role": {"maintain"},
85 85 })
86 86 if resp.StatusCode != 303 {
@@ -89,20 +89,20 @@ func TestAdminUsersLifecycle(t *testing.T) {
89 89 if acct, err = f.srv.Identity.Find(acct.Name); err != nil || acct.Role != access.Maintain {
90 90 t.Fatalf("role after set = %+v, %v", acct, err)
91 91 }
92 resp, _ = f.browserPost(t, "/users/"+acct.Name+"/role", cookie, url.Values{
92 resp, _ = f.browserPost(t, "/-/users/"+acct.Name+"/role", cookie, url.Values{
93 93 "csrf": {sess.CSRF}, "role": {"admin"},
94 94 })
95 95 if resp.StatusCode != 303 || !f.srv.Identity.IsAdmin(acct.Name) {
96 96 t.Fatalf("grant admin = %d, admin=%t", resp.StatusCode, f.srv.Identity.IsAdmin(acct.Name))
97 97 }
98 resp, _ = f.browserPost(t, "/users/"+acct.Name+"/role", cookie, url.Values{
98 resp, _ = f.browserPost(t, "/-/users/"+acct.Name+"/role", cookie, url.Values{
99 99 "csrf": {sess.CSRF}, "role": {"none"},
100 100 })
101 101 if resp.StatusCode != 303 || f.srv.Identity.IsAdmin(acct.Name) {
102 102 t.Fatalf("revoke admin = %d, admin=%t", resp.StatusCode, f.srv.Identity.IsAdmin(acct.Name))
103 103 }
104 104
105 resp, body = f.browserPost(t, "/users/"+acct.Name+"/invite", cookie, url.Values{"csrf": {sess.CSRF}})
105 resp, body = f.browserPost(t, "/-/users/"+acct.Name+"/invite", cookie, url.Values{"csrf": {sess.CSRF}})
106 106 if resp.StatusCode != 200 || !strings.Contains(body, "Enrollment link") {
107 107 t.Fatalf("refresh invite = %d:\n%s", resp.StatusCode, body)
108 108 }
@@ -120,18 +120,18 @@ func TestAdminUsersLifecycle(t *testing.T) {
120 120 if _, err := f.srv.Sessions.Create(writer.Name, nil, "test-browser"); err != nil {
121 121 t.Fatal(err)
122 122 }
123 resp, body = f.browserGet(t, "/users/"+writer.Name, cookie)
123 resp, body = f.browserGet(t, "/-/users/"+writer.Name, cookie)
124 124 if resp.StatusCode != 200 || !strings.Contains(body, "ssh-ed25519") || !strings.Contains(body, "test-browser") {
125 125 t.Fatalf("account detail with key and session = %d:\n%s", resp.StatusCode, body)
126 126 }
127 resp, _ = f.browserPost(t, "/users/"+writer.Name+"/sessions/revoke", cookie, url.Values{"csrf": {sess.CSRF}})
127 resp, _ = f.browserPost(t, "/-/users/"+writer.Name+"/sessions/revoke", cookie, url.Values{"csrf": {sess.CSRF}})
128 128 if resp.StatusCode != 303 {
129 129 t.Fatalf("revoke sessions = %d", resp.StatusCode)
130 130 }
131 131 if list, err := f.srv.Sessions.List(writer.Name); err != nil || len(list) != 0 {
132 132 t.Fatalf("sessions after revoke = %d, %v", len(list), err)
133 133 }
134 resp, _ = f.browserPost(t, "/users/"+writer.Name+"/keys/remove", cookie, url.Values{
134 resp, _ = f.browserPost(t, "/-/users/"+writer.Name+"/keys/remove", cookie, url.Values{
135 135 "csrf": {sess.CSRF}, "fingerprint": {writer.Keys[0].Fingerprint},
136 136 })
137 137 if resp.StatusCode != 303 {
@@ -145,16 +145,16 @@ func TestAdminUsersLifecycle(t *testing.T) {
145 145 t.Fatalf("writer still has %d key(s)", len(writer.Keys))
146 146 }
147 147
148 resp, body = f.browserPost(t, "/users/"+writer.Name+"/retire", cookie, url.Values{
148 resp, body = f.browserPost(t, "/-/users/"+writer.Name+"/retire", cookie, url.Values{
149 149 "csrf": {sess.CSRF}, "confirm": {"wrong"},
150 150 })
151 151 if resp.StatusCode != 400 || !strings.Contains(body, "Type the account name") {
152 152 t.Fatalf("retire without confirmation = %d:\n%s", resp.StatusCode, body)
153 153 }
154 resp, _ = f.browserPost(t, "/users/"+writer.Name+"/retire", cookie, url.Values{
154 resp, _ = f.browserPost(t, "/-/users/"+writer.Name+"/retire", cookie, url.Values{
155 155 "csrf": {sess.CSRF}, "confirm": {"wilbur"},
156 156 })
157 if resp.StatusCode != 303 || resp.Header.Get("Location") != "/admin?tab=accounts" {
157 if resp.StatusCode != 303 || resp.Header.Get("Location") != "/-/admin?tab=accounts" {
158 158 t.Fatalf("retire = %d %q", resp.StatusCode, resp.Header.Get("Location"))
159 159 }
160 160 writer, err = f.srv.Identity.Find(writer.Name)
@@ -166,7 +166,7 @@ func TestAdminUsersLifecycle(t *testing.T) {
166 166 }
167 167
168 168 // CSRF is checked before any mutation.
169 if resp, _ := f.browserPost(t, "/users/"+acct.Name+"/role", cookie, url.Values{"role": {"admin"}}); resp.StatusCode != 403 {
169 if resp, _ := f.browserPost(t, "/-/users/"+acct.Name+"/role", cookie, url.Values{"role": {"admin"}}); resp.StatusCode != 403 {
170 170 t.Fatalf("missing CSRF = %d, want 403", resp.StatusCode)
171 171 }
172 172 }
@@ -182,8 +182,8 @@ func TestAdminCreatesBotAccount(t *testing.T) {
182 182 }
183 183 sess, cookie := f.browserSession(t, f.ownerID)
184 184
185 resp, body := f.browserGet(t, "/admin?tab=accounts", cookie)
186 if resp.StatusCode != 200 || !strings.Contains(body, "Create a bot account") || !strings.Contains(body, `action="/users/bots"`) {
185 resp, body := f.browserGet(t, "/-/admin?tab=accounts", cookie)
186 if resp.StatusCode != 200 || !strings.Contains(body, "Create a bot account") || !strings.Contains(body, `action="/-/users/bots"`) {
187 187 t.Fatalf("users page has no bot form (%d):\n%s", resp.StatusCode, body)
188 188 }
189 189
@@ -191,8 +191,8 @@ func TestAdminCreatesBotAccount(t *testing.T) {
191 191 form := url.Values{
192 192 "csrf": {sess.CSRF}, "name": {"ci-runner"}, "role": {"contribute"}, "key": {botKey},
193 193 }
194 resp, body = f.browserPost(t, "/users/bots", cookie, form)
195 if resp.StatusCode != 303 || resp.Header.Get("Location") != "/users/ci-runner" {
194 resp, body = f.browserPost(t, "/-/users/bots", cookie, form)
195 if resp.StatusCode != 303 || resp.Header.Get("Location") != "/-/users/ci-runner" {
196 196 t.Fatalf("create bot = %d %q:\n%s", resp.StatusCode, resp.Header.Get("Location"), body)
197 197 }
198 198 acct, err := f.srv.Identity.Find("ci-runner")
@@ -202,14 +202,14 @@ func TestAdminCreatesBotAccount(t *testing.T) {
202 202 if !acct.Bot() || acct.Role != access.Contribute || len(acct.Keys) != 1 || acct.Keys[0].Line != botKey {
203 203 t.Fatalf("created bot = %+v", acct)
204 204 }
205 if _, list := f.browserGet(t, "/u", cookie); !strings.Contains(list, `<a class="people-name" href="/u/ci-runner">ci-runner</a> <span class="state">bot</span>`) ||
206 !strings.Contains(list, `<a href="/users/ci-runner">Manage</a>`) {
205 if _, list := f.browserGet(t, "/-/u", cookie); !strings.Contains(list, `<a class="people-name" href="/-/u/ci-runner">ci-runner</a> <span class="state">bot</span>`) ||
206 !strings.Contains(list, `<a href="/-/users/ci-runner">Manage</a>`) {
207 207 t.Fatalf("the users directory does not mark the bot or offer Manage:\n%s", list)
208 208 }
209 209
210 210 // The page that manages the bot says what it is and offers only actions a bot
211 211 // can use: no enrollment link, and no instance-admin role.
212 resp, body = f.browserGet(t, "/users/ci-runner", cookie)
212 resp, body = f.browserGet(t, "/-/users/ci-runner", cookie)
213 213 if resp.StatusCode != 200 || !strings.Contains(body, "ssh-ed25519") {
214 214 t.Fatalf("bot page = %d:\n%s", resp.StatusCode, body)
215 215 }
@@ -227,7 +227,7 @@ func TestAdminCreatesBotAccount(t *testing.T) {
227 227 // an account nobody can act as.
228 228 form.Set("name", "keyless-bot")
229 229 form.Del("key")
230 resp, body = f.browserPost(t, "/users/bots", cookie, form)
230 resp, body = f.browserPost(t, "/-/users/bots", cookie, form)
231 231 if resp.StatusCode != 400 || !strings.Contains(body, "Paste a public key for the bot") {
232 232 t.Fatalf("keyless bot = %d:\n%s", resp.StatusCode, body)
233 233 }
@@ -240,7 +240,7 @@ func TestAdminCreatesBotAccount(t *testing.T) {
240 240 form.Set("name", "admin-bot")
241 241 form.Set("role", "admin")
242 242 form.Set("key", adminKey)
243 resp, body = f.browserPost(t, "/users/bots", cookie, form)
243 resp, body = f.browserPost(t, "/-/users/bots", cookie, form)
244 244 if resp.StatusCode != 400 || !strings.Contains(body, "cannot hold that role") {
245 245 t.Fatalf("admin bot = %d:\n%s", resp.StatusCode, body)
246 246 }
@@ -253,7 +253,7 @@ func TestAdminCreatesBotAccount(t *testing.T) {
253 253 writerSess, writerCookie := f.browserSession(t, f.writerID)
254 254 form.Set("name", "writer-bot")
255 255 form.Set("role", "none")
256 resp, _ = f.browserPost(t, "/users/bots", writerCookie, url.Values{
256 resp, _ = f.browserPost(t, "/-/users/bots", writerCookie, url.Values{
257 257 "csrf": {writerSess.CSRF}, "name": {"writer-bot"}, "role": {"none"}, "key": {adminKey},
258 258 })
259 259 if resp.StatusCode != 404 {
@@ -264,12 +264,12 @@ func TestAdminCreatesBotAccount(t *testing.T) {
264 264 func TestAdminUsersRequiresInstanceAdmin(t *testing.T) {
265 265 t.Parallel()
266 266 f := browserFixture(t)
267 if resp, _ := f.browserGet(t, "/users", ""); resp.StatusCode != 303 {
267 if resp, _ := f.browserGet(t, "/-/users", ""); resp.StatusCode != 303 {
268 268 t.Fatalf("anonymous users page = %d, want login redirect", resp.StatusCode)
269 269 }
270 270 f.addWriter(t)
271 271 _, writerCookie := f.browserSession(t, f.writerID)
272 if resp, _ := f.browserGet(t, "/users", writerCookie); resp.StatusCode != 404 {
272 if resp, _ := f.browserGet(t, "/-/users", writerCookie); resp.StatusCode != 404 {
273 273 t.Fatalf("non-admin users page = %d, want 404", resp.StatusCode)
274 274 }
275 275 }
@@ -281,11 +281,11 @@ func TestUsersDirectoryHidesManageFromNonAdmins(t *testing.T) {
281 281 f := browserFixture(t)
282 282 _, cookie := f.browserSession(t, f.ownerID)
283 283
284 resp, body := f.browserGet(t, "/u", cookie)
284 resp, body := f.browserGet(t, "/-/u", cookie)
285 285 if resp.StatusCode != 200 {
286 286 t.Fatalf("GET /u = %d:\n%s", resp.StatusCode, body)
287 287 }
288 for _, gone := range []string{"<th>Manage</th>", `href="/users/keenan">Manage</a>`} {
288 for _, gone := range []string{"<th>Manage</th>", `href="/-/users/keenan">Manage</a>`} {
289 289 if strings.Contains(body, gone) {
290 290 t.Fatalf("a non-admin sees %q in the users directory:\n%s", gone, body)
291 291 }
@@ -294,8 +294,8 @@ func TestUsersDirectoryHidesManageFromNonAdmins(t *testing.T) {
294 294 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
295 295 t.Fatal(err)
296 296 }
297 if _, body := f.browserGet(t, "/u", cookie); !strings.Contains(body, "<th>Manage</th>") ||
298 !strings.Contains(body, `href="/users/keenan">Manage</a>`) {
297 if _, body := f.browserGet(t, "/-/u", cookie); !strings.Contains(body, "<th>Manage</th>") ||
298 !strings.Contains(body, `href="/-/users/keenan">Manage</a>`) {
299 299 t.Fatalf("an instance admin has no Manage action in the users directory:\n%s", body)
300 300 }
301 301 }