diff --git a/src/lib/router.ts b/src/lib/router.ts
index 4053b23bd..bf3bb1816 100644
--- a/src/lib/router.ts
+++ b/src/lib/router.ts
@@ -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",
diff --git a/src/views/repos/Header.svelte b/src/views/repos/Header.svelte
index f935fec0d..22f4ee185 100644
--- a/src/views/repos/Header.svelte
+++ b/src/views/repos/Header.svelte
@@ -163,6 +163,6 @@
{/if}
-
+
diff --git a/tests/unit/router.test.ts b/tests/unit/router.test.ts
index b08e3d7ff..64d55fdcb 100644
--- a/tests/unit/router.test.ts
+++ b/tests/unit/router.test.ts
@@ -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);
+ }
+});