mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
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:
co-authored by
Guillaume
parent
ab5aaa0382
commit
8cfcf11a88
+24
-2
@@ -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",
|
||||
|
||||
@@ -163,6 +163,6 @@
|
||||
</Button>
|
||||
{/if}
|
||||
<slot name="actions" />
|
||||
<SeedButton seedCount={repo.seeding} {repoId} />
|
||||
<SeedButton seedCount={repo.seeding} repoId={repo.rid} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user