feat(webui): redirect render=1 html to path-embedded token URL
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { promises as fs } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
authSecret: () => "test-secret-test-secret-test-secret",
|
||||
}));
|
||||
|
||||
// Canonicalized: safeResolve requires a realpath'd workspaceDir (macOS
|
||||
// /var -> /private/var would otherwise fail its containment check).
|
||||
const filesDir = await (async () => {
|
||||
const dir = join(tmpdir(), `evosci-file-route-test-${process.pid}`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
return fs.realpath(dir);
|
||||
})();
|
||||
|
||||
vi.mock("@/lib/server/conversationWorkspace", () => ({
|
||||
ConversationWorkspaceError: class ConversationWorkspaceError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
resolveConversationWorkspace: async () => ({
|
||||
deployment: {},
|
||||
threadId: "thread-1",
|
||||
scopeId: "scope-1",
|
||||
filesDir,
|
||||
runtimeDir: filesDir,
|
||||
}),
|
||||
}));
|
||||
|
||||
const routes = await import("./route");
|
||||
const { verifyRenderToken } = await import("@/lib/server/renderToken");
|
||||
|
||||
function get(query: string) {
|
||||
return routes.GET(
|
||||
new NextRequest(`http://localhost/api/workspace/file?threadId=thread-1&${query}`)
|
||||
);
|
||||
}
|
||||
|
||||
function redirectToken(res: Response): { token: string; rel: string } {
|
||||
const location = res.headers.get("location");
|
||||
expect(location).toBeTruthy();
|
||||
const match = new URL(location!).pathname.match(
|
||||
/^\/api\/workspace\/render\/([^/]+)\/(.+)$/
|
||||
);
|
||||
expect(match).toBeTruthy();
|
||||
return { token: match![1], rel: decodeURIComponent(match![2]) };
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(filesDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("GET /api/workspace/file?render=1", () => {
|
||||
beforeEach(async () => {
|
||||
await fs.rm(filesDir, { recursive: true, force: true });
|
||||
await fs.mkdir(join(filesDir, "dir"), { recursive: true });
|
||||
await fs.writeFile(join(filesDir, "dir", "report.html"), "<html>R</html>");
|
||||
await fs.writeFile(join(filesDir, "notes.md"), "# hi");
|
||||
});
|
||||
|
||||
it("redirects html render=1 to a path-embedded token URL", async () => {
|
||||
const res = await get("path=dir/report.html&render=1");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("cache-control")).toBe("no-store");
|
||||
const { token, rel } = redirectToken(res);
|
||||
expect(rel).toBe("dir/report.html");
|
||||
expect(verifyRenderToken(token)).toEqual({ threadId: "thread-1" });
|
||||
});
|
||||
|
||||
it("ignores render for non-html files", async () => {
|
||||
const res = await get("path=notes.md&render=1");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("text/markdown; charset=utf-8");
|
||||
expect(await res.text()).toBe("# hi");
|
||||
});
|
||||
|
||||
it("lets download take precedence over render for html", async () => {
|
||||
const res = await get("path=dir/report.html&render=1&download=1");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-disposition")).toMatch(/^attachment;/);
|
||||
expect(res.headers.get("content-type")).toBe("text/plain; charset=utf-8");
|
||||
});
|
||||
|
||||
it("keeps plain html (no render) as text/plain with a full sandbox", async () => {
|
||||
const res = await get("path=dir/report.html");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("text/plain; charset=utf-8");
|
||||
expect(res.headers.get("content-security-policy")).toBe("sandbox");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/lib/server/conversationWorkspace";
|
||||
|
||||
import { fileResponseHeaders } from "@/lib/server/workspaceFileHeaders";
|
||||
import { createRenderToken } from "@/lib/server/renderToken";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -35,10 +36,21 @@ export async function GET(request: NextRequest) {
|
||||
const download = request.nextUrl.searchParams.get("download") === "1";
|
||||
const render = request.nextUrl.searchParams.get("render") === "1";
|
||||
|
||||
const { filesDir: workspaceDir } = await resolveConversationWorkspace(request);
|
||||
const { filesDir: workspaceDir, threadId } = await resolveConversationWorkspace(request);
|
||||
// safeResolve canonicalizes + re-checks containment, so a symlink can't be
|
||||
// used to read a file outside the workspace (or a hidden/internal entry).
|
||||
const target = await safeResolve(workspaceDir, relPath);
|
||||
let target: string;
|
||||
let resolvedRel = relPath;
|
||||
try {
|
||||
target = await safeResolve(workspaceDir, relPath);
|
||||
} catch (error) {
|
||||
// Agents sometimes cite a file by bare name when it actually lives
|
||||
// under the conventional artifacts/ dir — retry there before failing.
|
||||
const bare = relPath.replace(/^\/+/, "");
|
||||
if (bare.includes("/")) throw error;
|
||||
resolvedRel = `artifacts/${bare}`;
|
||||
target = await safeResolve(workspaceDir, resolvedRel);
|
||||
}
|
||||
|
||||
const stat = await fs.stat(target);
|
||||
if (!stat.isFile()) {
|
||||
@@ -46,6 +58,23 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const ext = extname(target).slice(1).toLowerCase();
|
||||
|
||||
// render=1 for html bounces through a short-lived, path-embedded token.
|
||||
// The sandboxed iframe's subresources can't carry our session cookie and
|
||||
// trip isCrossOrigin; the token URL gives them a servable prefix that
|
||||
// relative paths resolve against (download=1 keeps its direct stream).
|
||||
if (ext === "html" && render && !download) {
|
||||
const token = createRenderToken(threadId);
|
||||
const encodedPath = resolvedRel.split("/").map(encodeURIComponent).join("/");
|
||||
const res = NextResponse.redirect(
|
||||
new URL(`/api/workspace/render/${token}/${encodedPath}`, request.url),
|
||||
302
|
||||
);
|
||||
// The token expires in minutes; never let a cache replay a stale redirect.
|
||||
res.headers.set("Cache-Control", "no-store");
|
||||
return res;
|
||||
}
|
||||
|
||||
const fileName = basename(target);
|
||||
const { contentType, disposition, csp } = fileResponseHeaders(
|
||||
ext,
|
||||
|
||||
Reference in New Issue
Block a user