feat(webui): redirect render=1 html to path-embedded token URL

This commit is contained in:
m4
2026-08-08 14:15:34 +08:00
parent 046129197a
commit 918cf741ac
2 changed files with 129 additions and 2 deletions
+98
View File
@@ -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");
});
});
+31 -2
View File
@@ -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,