Decode percent-encoded identifiers in URLs

Cloudflare's single-page-application fallback percent-encodes the
path on a fresh load or reload, so `rad:z4V1...` arrives as
`rad%3Az4V1...` and leaks into links and the seed command.

Only identifiers are decoded: revision and path segments stay
encoded, because branch and tag lookups are keyed on the encoded
name.

The seed command needs a second fix: the repo path segment is an
alias when one was used, and `rad seed <alias>` is not valid, the
`rad seed` command only supports RIDs and not aliases.

Co-Authored-By: Guillaume <guillaume.alabre@gmail.com>
This commit is contained in:
Rūdolfs Ošiņš
2026-08-25 14:52:32 +02:00
co-authored by Guillaume
parent ab5aaa0382
commit 8cfcf11a88
3 changed files with 142 additions and 3 deletions
+24 -2
View File
@@ -418,6 +418,23 @@ export function extractBaseUrl(hostAndPort: string): BaseUrl {
};
}
// Cloudflare's static-asset handler percent-encodes the path when it serves
// the single-page-application fallback, so a fresh load or reload arrives with
// `rad%3Az4V1...` in place of `rad:z4V1...`. Identifier segments are decoded
// back to their canonical form here, at the point the route is parsed, so the
// encoded shape never reaches link building or anything user-visible. Only
// identifiers are decoded: revision and path segments are deliberately kept
// encoded, because branch and tag lookups are keyed on the encoded name.
// Decoding throws on a stray `%` in a repo alias, which is operator-supplied
// and unvalidated, so an undecodable segment is passed through untouched.
function decodeIdentifier(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
function urlToRoute(url: URL): Route | null {
// Normalize a trailing slash (`/guides/protocol/` → `/guides/protocol`) so
// both the strict marketing resolver and the lenient repo resolver agree.
@@ -437,11 +454,16 @@ function urlToRoute(url: URL): Route | null {
if (id === "users") {
const did = segments.shift();
if (did) {
return { resource: "users", baseUrl, did };
return { resource: "users", baseUrl, did: decodeIdentifier(did) };
}
return null;
} else if (id) {
return resolveRepoRoute(baseUrl, id, segments, url.search);
return resolveRepoRoute(
baseUrl,
decodeIdentifier(id),
segments,
url.search,
);
} else {
return {
resource: "nodes",
+1 -1
View File
@@ -163,6 +163,6 @@
</Button>
{/if}
<slot name="actions" />
<SeedButton seedCount={repo.seeding} {repoId} />
<SeedButton seedCount={repo.seeding} repoId={repo.rid} />
</div>
</div>
+117
View File
@@ -361,3 +361,120 @@ describe("extractBaseUrl", () => {
});
});
});
// Cloudflare's single-page-application fallback percent-encodes the path on a
// fresh load or reload, so these paths are what the router actually receives
// in production for a URL the user sees and copies in its decoded form.
describe("percent-encoded path segments", () => {
const node = {
hostname: "example.node.tld",
scheme: "http",
port: config.nodes.defaultHttpdPort,
};
test("encoded repo id is decoded", () => {
expectPathToRoute(
"/nodes/example.node.tld/rad%3AzKtT7DmF9H34KkvcKj9PHW19WzjT/patches/68579c8c8c792d39522c31544ed696b6e3a607fb",
{
resource: "repo.patch",
node,
repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
patch: "68579c8c8c792d39522c31544ed696b6e3a607fb",
},
);
});
test("decoded repo id is left as is", () => {
expectPathToRoute(
"/nodes/example.node.tld/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
{
resource: "repo.source",
node,
repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
route: "",
},
);
});
test("encoded did is decoded", () => {
expectPathToRoute(
"/nodes/example.node.tld/users/did%3Akey%3Az6MktaNvN1tVAno62rCfqvV8bBoTeAyHjuJz9YZjmpBmnCwd",
{
resource: "users",
baseUrl: node,
did: "did:key:z6MktaNvN1tVAno62rCfqvV8bBoTeAyHjuJz9YZjmpBmnCwd",
},
);
});
// Repo aliases are operator-supplied and unvalidated, so a segment that is
// not a well-formed encoding must not throw out of route parsing.
test("undecodable repo id is passed through", () => {
expectPathToRoute("/nodes/example.node.tld/100%", {
resource: "repo.source",
node,
repo: "100%",
route: "",
});
});
// Revision segments must stay encoded: branch and tag lookups are keyed on
// the encoded name, and decoding `%2F` here would turn a tag like
// `v1.0/release` into two path segments.
test("encoded tag name in a tree route stays encoded", () => {
expectPathToRoute(
"/nodes/example.node.tld/rad%3AzKtT7DmF9H34KkvcKj9PHW19WzjT/tree/v1.0%2Frelease",
{
resource: "repo.source",
node,
repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
route: "v1.0%2Frelease",
},
);
});
test("encoded tag name in a history route stays encoded", () => {
expectPathToRoute(
"/nodes/example.node.tld/rad%3AzKtT7DmF9H34KkvcKj9PHW19WzjT/history/v1.0%2Frelease",
{
resource: "repo.history",
node,
repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
revision: "v1.0%2Frelease",
},
);
});
test("encoded tag name under an explicit peer stays encoded", () => {
expectPathToRoute(
"/nodes/example.node.tld/rad%3AzKtT7DmF9H34KkvcKj9PHW19WzjT/remotes/z6MktaNvN1tVAno62rCfqvV8bBoTeAyHjuJz9YZjmpBmnCwd/tree/v1.0%2Frelease",
{
resource: "repo.source",
node,
repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
peer: "z6MktaNvN1tVAno62rCfqvV8bBoTeAyHjuJz9YZjmpBmnCwd",
route: "v1.0%2Frelease",
},
);
});
// Decoding the repo id changes what `routeToPath` emits, which is what heals
// the address bar and the "Copy link" button on subsequent navigation.
test("an encoded path round-trips to its decoded form", () => {
const route = testExports.urlToRoute(
new URL(
"/nodes/example.node.tld/rad%3AzKtT7DmF9H34KkvcKj9PHW19WzjT/tree/v1.0%2Frelease",
"http://localhost/",
),
);
expect(testExports.routeToPath(route!)).toEqual(
"/nodes/example.node.tld/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT/tree/v1.0%2Frelease",
);
});
function expectPathToRoute(relativeUrl: string, route: Route | null) {
expect(
testExports.urlToRoute(new URL(relativeUrl, "http://localhost/")),
).toEqual(route);
}
});