Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/viewer/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,16 @@ export function createViewerServer(opts: ServerOpts) {
if (parts[0] !== "w" || !parts[1]) return send(res, 404, "not found");
const wsid = parts[1];
if (wsid === SHARES_WSID || wsid === TEMPLATES_WSID) return send(res, 404, "not found"); // reserved store wsids, never servable
// Trailing slash matters: index.html's relative assets (viewer.js/style.css) must resolve under
// /w/<wsid>/ — without it the browser fetches /w/viewer.js, which the SPA fallback answers with
// HTML, so the module load fails and the page is blank. Redirect the bare form so a hand-typed/
// clicked link without the slash still works (mirrors the /s/<token> redirect above). The URL
// fragment (#scope) is preserved by the browser across a fragment-less redirect.
if (parts.length === 2 && !url.pathname.endsWith("/")) {
res.writeHead(302, { location: `/w/${wsid}/${url.search}` });
res.end();
return;
}
const action = parts[2] ?? "";

// The built-in demo workspace is READ-ONLY and has no agent: every mutating endpoint is a POST
Expand Down
17 changes: 17 additions & 0 deletions packages/viewer/test/server-static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,23 @@ describe("static serving", () => {
expect(await r.text()).toContain("VIEWER_INDEX");
});

// A workspace URL without the trailing slash must redirect to the slashed form. Otherwise the
// browser resolves index.html's relative <script src="viewer.js"> against /w/ — fetching
// /w/viewer.js, which the SPA fallback answers with HTML, so the module load fails and the page
// is blank. (The /s/<token> share path already does this; /w/<wsid> must match.)
it("redirects /w/<wsid> (no trailing slash) to /w/<wsid>/", async () => {
const r = await fetch(`${base}/w/ws1`, { redirect: "manual" });
expect(r.status).toBe(302);
expect(r.headers.get("location")).toBe("/w/ws1/");
});

it("does not redirect the slashed workspace root or asset paths", async () => {
const root = await fetch(`${base}/w/ws1/`, { redirect: "manual" });
expect(root.status).toBe(200);
const asset = await fetch(`${base}/w/ws1/viewer-ABC123.js`, { redirect: "manual" });
expect(asset.status).toBe(200);
});

it("does not serve files outside publicDir via traversal", async () => {
const r = await fetch(`${base}/w/ws1/..%2f..%2fpackage.json`);
expect(await r.text()).not.toContain("termchart-viewer");
Expand Down
Loading