diff --git a/src/components/input/https-json/common.js b/src/components/input/https-json/common.js index 39d3a869..919a9cb8 100644 --- a/src/components/input/https-json/common.js +++ b/src/components/input/https-json/common.js @@ -24,6 +24,19 @@ export function serverId(server) { return `${server.host}${server.dir}`; } +/** + * Normalise a directory path to its canonical form: a single leading slash, + * no trailing slash (the root stays "/") and no repeated slashes, so stray + * `//` inputs can never produce double-slash URLs. + * + * @param {string} dir + * @returns {string} + */ +export function normalizeDir(dir) { + const cleaned = dir.replace(/\/{2,}/g, "/").replace(/^\/|\/$/g, ""); + return cleaned === "" ? "/" : "/" + cleaned; +} + /** * Build a https-json:// URI. * Protocol can be embedded in host (eg. http://localhost:8080) and is stored @@ -56,7 +69,7 @@ export function parseURI(uriString) { if (!uri.host) return undefined; const qs = QS.parse(uri.query || ""); - const dir = typeof qs.dir === "string" ? qs.dir : "/"; + const dir = typeof qs.dir === "string" ? normalizeDir(qs.dir) : "/"; const protocol = typeof qs.protocol === "string" ? qs.protocol : undefined; const exclude = typeof qs.exclude === "string" ? qs.exclude.split(",").filter(Boolean) @@ -85,7 +98,9 @@ export function toHttpUrl(server, path = "") { : "https" }://${server.host}`; - return base.replace(/\/$/, "") + (path ? "/" + path.replace(/^\//, "") : ""); + // Strip ALL leading/trailing slashes on both sides of the join, so a stray + // `//` in host or path can never produce a double-slash URL. + return base.replace(/\/+$/, "") + (path ? "/" + path.replace(/^\/+/, "") : ""); } /** diff --git a/src/facets/connect/https-json/facet.js b/src/facets/connect/https-json/facet.js index 9dbbacaa..fec10be1 100644 --- a/src/facets/connect/https-json/facet.js +++ b/src/facets/connect/https-json/facet.js @@ -3,7 +3,7 @@ import { html } from "lit-html"; import * as Output from "~/common/output.js"; import { SCHEME } from "~/components/input/https-json/constants.js"; -import { buildURI, parseURI, serverId } from "~/components/input/https-json/common.js"; +import { buildURI, normalizeDir, parseURI, serverId } from "~/components/input/https-json/common.js"; import { effect } from "~/common/signal.js"; import foundation from "~/common/foundation.js"; @@ -122,7 +122,7 @@ async function removeServer(uri) { async function addServer() { const host = hostInput.value?.trim(); - const dir = dirInput.value?.trim() || "/"; + const dir = normalizeDir(dirInput.value?.trim() || "/"); const exclude = (excludeInput.value ?? "").split(",").map((s) => s.trim()).filter(Boolean); if (!host) return; diff --git a/tests/components/input/https-json/integration.ts b/tests/components/input/https-json/integration.ts index 812fc201..c66a3ce3 100644 --- a/tests/components/input/https-json/integration.ts +++ b/tests/components/input/https-json/integration.ts @@ -30,12 +30,10 @@ let port: number; beforeAll(async () => { const started = await mockServer((req, url) => { - // Normalise path: ensure leading slash, no trailing slash (except root) - let dir = url.pathname; - if (!dir.startsWith("/")) dir = "/" + dir; - if (dir.length > 1 && dir.endsWith("/")) dir = dir.slice(0, -1); - - const entries = (FILESYSTEM as Record)[dir]; + // Match the pathname exactly. The client must never send `//` URLs, so a + // repeated slash would 404 here, mirroring strict servers that do not + // normalise paths. + const entries = (FILESYSTEM as Record)[url.pathname]; if (!entries) return new Response("", { status: 404 }); return new Response(JSON.stringify(entries), { @@ -52,6 +50,12 @@ afterAll(async () => { }); describe("components/input/https-json (integration)", () => { + it("parseURI normalises repeated slashes in dir", () => { + const uri = buildURI({ host: `127.0.0.1:${port}`, dir: "//music/" }, ""); + const parsed = parseURI(uri); + expect(parsed?.server.dir).toBe("/music"); + }); + it("consult returns true when the server responds ok", async () => { const uri = buildURI({ host: `127.0.0.1:${port}`, dir: "/" }, ""); const result = await Worker.consult(uri); @@ -94,6 +98,25 @@ describe("components/input/https-json (integration)", () => { expect(uris.some((u) => u.includes("readme.txt"))).toBe(false); }); + it("list works with a dir containing repeated slashes (legacy `//` URIs)", async () => { + const uri = buildURI({ host: `127.0.0.1:${port}`, dir: "//music/" }, ""); + + const tracks = await Worker.list([{ + $type: "sh.diffuse.output.track", + id: "placeholder-2", + kind: "placeholder", + uri, + }]); + + // The `//` dir is normalised to `/music`, so the strict mock server + // (which 404s any double-slash path) still serves the listing. + expect(tracks.length).toBe(3); + const uris = tracks.map((t) => t.uri); + expect(uris.some((u) => u.includes("track1.mp3"))).toBe(true); + expect(uris.some((u) => u.includes("song1.flac"))).toBe(true); + expect(uris.some((u) => u.includes("song2.mp3"))).toBe(true); + }); + it("list respects exclude list", async () => { const serverObj = { host: `127.0.0.1:${port}`, dir: "/", exclude: ["music"] }; const uri = buildURI(serverObj, "");