From 918cf741acb4740fe35eb99b1d9d4c4f9a7cec37 Mon Sep 17 00:00:00 2001 From: m4 Date: Sat, 8 Aug 2026 14:15:34 +0800 Subject: [PATCH] feat(webui): redirect render=1 html to path-embedded token URL --- src/app/api/workspace/file/route.test.ts | 98 ++++++++++++++++++++++++ src/app/api/workspace/file/route.ts | 33 +++++++- 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 src/app/api/workspace/file/route.test.ts diff --git a/src/app/api/workspace/file/route.test.ts b/src/app/api/workspace/file/route.test.ts new file mode 100644 index 0000000..6848131 --- /dev/null +++ b/src/app/api/workspace/file/route.test.ts @@ -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"), "R"); + 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"); + }); +}); diff --git a/src/app/api/workspace/file/route.ts b/src/app/api/workspace/file/route.ts index fa81a81..122a056 100644 --- a/src/app/api/workspace/file/route.ts +++ b/src/app/api/workspace/file/route.ts @@ -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,