diff --git a/.changeset/fn-7073-file-viewer-inline-preview.md b/.changeset/fn-7073-file-viewer-inline-preview.md
new file mode 100644
index 0000000000..a5307edacc
--- /dev/null
+++ b/.changeset/fn-7073-file-viewer-inline-preview.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Fix Files viewer previews for images, video, audio, and PDFs.
+category: fix
+dev: Preview URLs request inline file responses with safe MIME, nosniff, and sandbox CSP headers while downloads remain attachments.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 8ca10baa01..1cc5482091 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -681,7 +681,7 @@ The Files modal provides a workspace-aware file browser and editor.
- Use **New File** or **New Folder** in the browser header to create entries in the current folder; new files open in the editor after creation
- Source/text editing supports a **Line #** header toggle to show or hide line numbers in the editor gutter
- The line-number preference is saved per project and restored automatically when you switch projects
-- Known image, video/movie, audio, and PDF files render browser-native read-only previews from the selected project or task workspace download URL; text files remain editable, and unknown binary files keep the read-only editor fallback
+- Known image, video/movie, audio, and PDF files render browser-native read-only previews inline with their real content type from the selected project or task workspace download URL; the explicit **Download** action still saves files as attachments, text files remain editable, and unknown binary files keep the read-only editor fallback
- In editable files and markdown preview mode, highlighted text exposes **Add comment** so you can send the file path, selected snippet, best-effort line range, and your note to the **New Task** dialog without copy/paste
## Memory View
diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts
index 103b8246d1..4b05589b31 100644
--- a/packages/dashboard/app/api/legacy.ts
+++ b/packages/dashboard/app/api/legacy.ts
@@ -3511,11 +3511,18 @@ export function renameFile(workspace: string, filePath: string, newName: string,
}
/** Get the download URL for a single file in a workspace. */
-export function downloadFileUrl(workspace: string, filePath: string, projectId?: string): string {
+export function downloadFileUrl(workspace: string, filePath: string, projectId?: string, options?: { inline?: boolean }): string {
const query = new URLSearchParams({ workspace });
if (projectId) {
query.set("projectId", projectId);
}
+ /**
+ * FNXC:FileBrowser 2026-06-26-00:00:
+ * Browser-native preview consumers request `inline=1` so the shared download route serves renderable MIME types with inline disposition. The explicit Download action intentionally omits this option to preserve attachment downloads.
+ */
+ if (options?.inline === true) {
+ query.set("inline", "1");
+ }
return `/api/files/${encodeURIComponent(filePath)}/download?${query.toString()}`;
}
diff --git a/packages/dashboard/app/components/DockFilesView.tsx b/packages/dashboard/app/components/DockFilesView.tsx
index ff16a48b76..48fa584837 100644
--- a/packages/dashboard/app/components/DockFilesView.tsx
+++ b/packages/dashboard/app/components/DockFilesView.tsx
@@ -111,7 +111,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
if (!selectedFile || !selectedPreviewKind) {
return null;
}
- return downloadFileUrl("project", selectedFile, projectId);
+ return downloadFileUrl("project", selectedFile, projectId, { inline: true });
}, [projectId, selectedFile, selectedPreviewKind]);
/*
diff --git a/packages/dashboard/app/components/FileBrowserModal.tsx b/packages/dashboard/app/components/FileBrowserModal.tsx
index fa25d8704b..b99be00992 100644
--- a/packages/dashboard/app/components/FileBrowserModal.tsx
+++ b/packages/dashboard/app/components/FileBrowserModal.tsx
@@ -327,7 +327,7 @@ export function FileBrowserModal({
*/
const previewUrl = useMemo(() => {
if (!selectedFile || !selectedPreviewKind) return null;
- return downloadFileUrl(currentWorkspace, selectedFile, projectId);
+ return downloadFileUrl(currentWorkspace, selectedFile, projectId, { inline: true });
}, [currentWorkspace, projectId, selectedFile, selectedPreviewKind]);
const selectedPreviewLabel = selectedFile
diff --git a/packages/dashboard/app/components/__tests__/DockFilesView.test.tsx b/packages/dashboard/app/components/__tests__/DockFilesView.test.tsx
index ad5491f842..9d0cb90f7c 100644
--- a/packages/dashboard/app/components/__tests__/DockFilesView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/DockFilesView.test.tsx
@@ -47,15 +47,16 @@ vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
const mockFetchContent = vi.fn(() => Promise.resolve({ content: "# hi" }));
const mockSaveContent = vi.fn(() => Promise.resolve({ mtime: "2026-01-15T10:31:00Z" }));
-const mockDownloadFileUrl = vi.fn((workspace: string, filePath: string, projectId?: string) => {
+const mockDownloadFileUrl = vi.fn((workspace: string, filePath: string, projectId?: string, options?: { inline?: boolean }) => {
const params = new URLSearchParams({ workspace });
if (projectId) params.set("projectId", projectId);
- return `/api/files/${encodeURIComponent(filePath)}?${params.toString()}`;
+ if (options?.inline) params.set("inline", "1");
+ return `/api/files/${encodeURIComponent(filePath)}/download?${params.toString()}`;
});
vi.mock("../../api", () => ({
fetchWorkspaceFileContent: (...args: unknown[]) => mockFetchContent(...(args as [])),
saveWorkspaceFileContent: (...args: unknown[]) => mockSaveContent(...(args as [])),
- downloadFileUrl: (...args: unknown[]) => mockDownloadFileUrl(...(args as [string, string, string | undefined])),
+ downloadFileUrl: (...args: unknown[]) => mockDownloadFileUrl(...(args as [string, string, string | undefined, { inline?: boolean } | undefined])),
}));
const capturedEditorHookCalls: Array<{
@@ -284,7 +285,7 @@ describe("DockFilesView shared current-file state", () => {
await waitFor(() => expect(document.querySelector(selector)).toBeInTheDocument());
const preview = document.querySelector(selector);
- expect(preview).toHaveAttribute(attr, `/api/files/${encodeURIComponent(file)}?workspace=project&projectId=${PROJECT_ID}`);
+ expect(preview).toHaveAttribute(attr, `/api/files/${encodeURIComponent(file)}/download?workspace=project&projectId=${PROJECT_ID}&inline=1`);
if (selector.startsWith("video") || selector.startsWith("audio")) {
expect(preview).toHaveAttribute("controls");
expect(preview).toHaveAttribute("aria-label", `Preview for ${file}`);
@@ -295,7 +296,7 @@ describe("DockFilesView shared current-file state", () => {
expect(screen.queryByTestId("mock-file-editor")).toBeNull();
expect(screen.queryByTestId("right-dock-files-save")).toBeNull();
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: file, enabled: false, projectId: PROJECT_ID });
- expect(mockDownloadFileUrl).toHaveBeenLastCalledWith("project", file, PROJECT_ID);
+ expect(mockDownloadFileUrl).toHaveBeenLastCalledWith("project", file, PROJECT_ID, { inline: true });
});
it("keeps text files editable with Save when changes exist", async () => {
@@ -306,6 +307,7 @@ describe("DockFilesView shared current-file state", () => {
expect(screen.getByTestId("right-dock-files-save")).toBeEnabled();
expect(document.querySelector(".file-browser-preview")).not.toBeInTheDocument();
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: "changed.txt", enabled: true, projectId: PROJECT_ID });
+ expect(mockDownloadFileUrl).not.toHaveBeenCalledWith("project", "changed.txt", PROJECT_ID, { inline: true });
});
it("keeps known non-preview binary files read-only without rendering garbage editor content", () => {
@@ -317,12 +319,14 @@ describe("DockFilesView shared current-file state", () => {
expect(screen.queryByTestId("right-dock-files-save")).toBeNull();
expect(document.querySelector(".file-browser-preview")).not.toBeInTheDocument();
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: "build/output.bin", enabled: false, projectId: PROJECT_ID });
+ expect(mockDownloadFileUrl).not.toHaveBeenCalledWith("project", "build/output.bin", PROJECT_ID, { inline: true });
});
it("clears stale preview state when switching from a preview file back to text", async () => {
render();
fireEvent.click(screen.getByText("assets/Logo.PNG"));
await waitFor(() => expect(document.querySelector("img.file-browser-preview-media--image")).toBeInTheDocument());
+ expect(document.querySelector("img.file-browser-preview-media--image")).toHaveAttribute("src", expect.stringContaining("inline=1"));
fireEvent.click(screen.getByText("readme.md"));
await waitFor(() => expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md"));
diff --git a/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx b/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx
index d7b7d53969..c833990b06 100644
--- a/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx
@@ -825,6 +825,7 @@ describe("FileBrowserModal", () => {
expect(preview).toBeInTheDocument();
expect(preview).toHaveAttribute(attribute, expect.stringContaining(encodeURIComponent(name)));
expect(preview).toHaveAttribute(attribute, expect.stringContaining("workspace=project"));
+ expect(preview).toHaveAttribute(attribute, expect.stringContaining("inline=1"));
if (selector.startsWith("video") || selector.startsWith("audio")) {
expect(preview).toHaveAttribute("controls");
expect(preview).toHaveAttribute("aria-label", `Preview for ${name}`);
@@ -858,6 +859,7 @@ describe("FileBrowserModal", () => {
expect(video).toBeInTheDocument();
expect(video).toHaveAttribute("src", expect.stringContaining("workspace=FN-001"));
expect(video).toHaveAttribute("src", expect.stringContaining("projectId=proj-1"));
+ expect(video).toHaveAttribute("src", expect.stringContaining("inline=1"));
expect(video).toHaveAttribute("src", expect.stringContaining("movie.mov"));
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-001", "movie.mov", false, "proj-1");
});
@@ -875,6 +877,7 @@ describe("FileBrowserModal", () => {
await waitFor(() => expect(document.querySelector("iframe.file-browser-preview-media--pdf")).toBeInTheDocument());
const pdf = document.querySelector("iframe.file-browser-preview-media--pdf");
expect(pdf).toHaveAttribute("src", expect.stringContaining(encodeURIComponent("docs/MANUAL.PDF")));
+ expect(pdf).toHaveAttribute("src", expect.stringContaining("inline=1"));
expect(pdf).toHaveAttribute("title", "Preview for docs/MANUAL.PDF");
expect(mockSetPath).toHaveBeenCalledWith("docs");
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "docs/MANUAL.PDF", false, undefined);
@@ -936,7 +939,10 @@ describe("FileBrowserModal", () => {
await selectFile("clip.mp4");
expect(document.querySelector("iframe.file-browser-preview-media--pdf")).not.toBeInTheDocument();
- expect(document.querySelector("video.file-browser-preview-media--video")).toBeInTheDocument();
+ const video = document.querySelector("video.file-browser-preview-media--video");
+ expect(video).toBeInTheDocument();
+ expect(video).toHaveAttribute("src", expect.stringContaining("clip.mp4"));
+ expect(video).toHaveAttribute("src", expect.stringContaining("inline=1"));
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "clip.mp4", false, undefined);
});
@@ -955,7 +961,9 @@ describe("FileBrowserModal", () => {
await selectFile("voice.mp3");
expect(screen.getByLabelText("Back to file list")).toBeInTheDocument();
- expect(document.querySelector("audio.file-browser-preview-media--audio")).toBeInTheDocument();
+ const audio = document.querySelector("audio.file-browser-preview-media--audio");
+ expect(audio).toBeInTheDocument();
+ expect(audio).toHaveAttribute("src", expect.stringContaining("inline=1"));
expect(document.querySelector(".file-browser-content.mobile.active")).toBeInTheDocument();
});
});
diff --git a/packages/dashboard/src/routes/__tests__/register-file-workspace-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-file-workspace-routes.test.ts
new file mode 100644
index 0000000000..7315e32c3f
--- /dev/null
+++ b/packages/dashboard/src/routes/__tests__/register-file-workspace-routes.test.ts
@@ -0,0 +1,141 @@
+// @vitest-environment node
+
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import express from "express";
+import type { TaskStore } from "@fusion/core";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { registerFileWorkspaceRoutes } from "../register-file-workspace-routes.js";
+import type { ApiRoutesContext } from "../types.js";
+import { request as REQUEST } from "../../test-request.js";
+
+const tempRoots: string[] = [];
+
+async function makeRoot(): Promise {
+ const root = await mkdtemp(join(tmpdir(), "fusion-file-workspace-routes-"));
+ tempRoots.push(root);
+ return root;
+}
+
+function makeApp(store: Partial) {
+ const router = express.Router();
+ const app = express();
+ registerFileWorkspaceRoutes({
+ router,
+ store: store as TaskStore,
+ runtimeLogger: {} as never,
+ planningLogger: {} as never,
+ chatLogger: {} as never,
+ getProjectIdFromRequest: vi.fn(),
+ getScopedStore: vi.fn(async () => store as TaskStore),
+ getProjectContext: vi.fn(async () => ({ store: store as TaskStore, engine: undefined, projectId: undefined })),
+ prioritizeProjectsForCurrentDirectory: vi.fn((projects) => projects),
+ emitRemoteRouteDiagnostic: vi.fn(),
+ emitAuthSyncAuditLog: vi.fn(),
+ parseScopeParam: vi.fn(),
+ resolveAutomationStore: vi.fn(),
+ resolveRoutineStore: vi.fn(),
+ resolveRoutineRunner: vi.fn(),
+ registerDispose: vi.fn(),
+ dispose: vi.fn(),
+ rethrowAsApiError(error: unknown, fallbackMessage?: string): never {
+ throw error instanceof Error ? error : new Error(fallbackMessage ?? String(error));
+ },
+ } as ApiRoutesContext);
+ app.use("/api", router);
+ return app;
+}
+
+async function writeFixture(root: string, filePath: string, content = "fixture-bytes"): Promise {
+ const pathParts = filePath.split("/");
+ const fileName = pathParts.pop();
+ if (!fileName) {
+ throw new Error(`Fixture path must include a file name: ${filePath}`);
+ }
+ const directoryPath = join(root, ...pathParts);
+ await mkdir(directoryPath, { recursive: true });
+ await writeFile(join(directoryPath, fileName), Buffer.from(content));
+}
+
+afterEach(async () => {
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
+});
+
+describe("file workspace download route", () => {
+ it.each([
+ ["assets/logo.png", "image/png"],
+ ["icons/mark.svg", "image/svg+xml"],
+ ["media/demo.mp4", "video/mp4"],
+ ["audio/theme.mp3", "audio/mpeg"],
+ ["docs/spec.pdf", "application/pdf"],
+ ["nested/CAPTURE.PNG", "image/png"],
+ ])("serves previewable file %s inline with renderable headers", async (filePath, expectedContentType) => {
+ const root = await makeRoot();
+ await writeFixture(root, filePath);
+ const app = makeApp({ getRootDir: vi.fn(() => root) });
+
+ const res = await REQUEST(app, "GET", `/api/files/${encodeURIComponent(filePath)}/download?workspace=project&inline=1`);
+
+ expect(res.status).toBe(200);
+ expect(res.headers["content-type"]).toBe(expectedContentType);
+ expect(res.headers["content-disposition"]).toBe(`inline; filename="${filePath.split("/").at(-1)}"`);
+ expect(res.headers["x-content-type-options"]).toBe("nosniff");
+ expect(res.headers["content-security-policy"]).toBe("sandbox");
+ expect(res.body).toBe("fixture-bytes");
+ });
+
+ it.each([
+ "assets/logo.png",
+ "icons/mark.svg",
+ "media/demo.mp4",
+ "audio/theme.mp3",
+ "docs/spec.pdf",
+ ])("keeps the default download contract for %s as attachment octet-stream", async (filePath) => {
+ const root = await makeRoot();
+ await writeFixture(root, filePath);
+ const app = makeApp({ getRootDir: vi.fn(() => root) });
+
+ const res = await REQUEST(app, "GET", `/api/files/${encodeURIComponent(filePath)}/download?workspace=project`);
+
+ expect(res.status).toBe(200);
+ expect(res.headers["content-type"]).toBe("application/octet-stream");
+ expect(res.headers["content-disposition"]).toBe(`attachment; filename="${filePath.split("/").at(-1)}"`);
+ expect(res.headers["x-content-type-options"]).toBeUndefined();
+ expect(res.headers["content-security-policy"]).toBeUndefined();
+ expect(res.body).toBe("fixture-bytes");
+ });
+
+ it("falls back to attachment for inline requests with unknown binary extensions", async () => {
+ const root = await makeRoot();
+ await writeFixture(root, "archives/build.zip");
+ const app = makeApp({ getRootDir: vi.fn(() => root) });
+
+ const res = await REQUEST(app, "GET", `/api/files/${encodeURIComponent("archives/build.zip")}/download?workspace=project&inline=1`);
+
+ expect(res.status).toBe(200);
+ expect(res.headers["content-type"]).toBe("application/octet-stream");
+ expect(res.headers["content-disposition"]).toBe("attachment; filename=\"build.zip\"");
+ expect(res.headers["x-content-type-options"]).toBeUndefined();
+ expect(res.headers["content-security-policy"]).toBeUndefined();
+ });
+
+ it("serves task workspace preview files inline while preserving projectId query propagation", async () => {
+ const root = await makeRoot();
+ const taskDir = join(root, ".fusion", "tasks", "FN-123");
+ await writeFixture(taskDir, "screens/shot.JPG");
+ const store = {
+ getRootDir: vi.fn(() => root),
+ getTask: vi.fn(async () => ({ id: "FN-123", title: "Task" })),
+ getTaskDir: vi.fn(() => taskDir),
+ };
+ const app = makeApp(store);
+
+ const res = await REQUEST(app, "GET", `/api/files/${encodeURIComponent("screens/shot.JPG")}/download?workspace=FN-123&projectId=project-a&inline=true`);
+
+ expect(res.status).toBe(200);
+ expect(res.headers["content-type"]).toBe("image/jpeg");
+ expect(res.headers["content-disposition"]).toBe("inline; filename=\"shot.JPG\"");
+ expect(store.getTask).toHaveBeenCalledWith("FN-123");
+ });
+});
diff --git a/packages/dashboard/src/routes/register-file-workspace-routes.ts b/packages/dashboard/src/routes/register-file-workspace-routes.ts
index 2b93fd58e2..54542aee2d 100644
--- a/packages/dashboard/src/routes/register-file-workspace-routes.ts
+++ b/packages/dashboard/src/routes/register-file-workspace-routes.ts
@@ -1,5 +1,6 @@
import { access } from "node:fs/promises";
import { createReadStream } from "node:fs";
+import { extname } from "node:path";
import type { Request } from "express";
import { ApiError, badRequest } from "../api-error.js";
import {
@@ -33,6 +34,45 @@ function extractFileParams(req: Request): { filePath: string; workspace: string
return { filePath, workspace };
}
+/*
+FNXC:FileBrowser 2026-06-26-00:00:
+The server-side preview MIME map must stay aligned with `packages/dashboard/app/utils/file-preview-kind.ts` so every browser-previewable extension rendered by the file viewer has exactly one safe inline content type, while all unknown extensions keep attachment semantics.
+*/
+const INLINE_PREVIEW_CONTENT_TYPES = new Map([
+ [".png", "image/png"],
+ [".jpg", "image/jpeg"],
+ [".jpeg", "image/jpeg"],
+ [".gif", "image/gif"],
+ [".webp", "image/webp"],
+ [".bmp", "image/bmp"],
+ [".ico", "image/x-icon"],
+ [".svg", "image/svg+xml"],
+ [".svgz", "image/svg+xml"],
+ [".avif", "image/avif"],
+ [".mp4", "video/mp4"],
+ [".webm", "video/webm"],
+ [".ogg", "video/ogg"],
+ [".ogv", "video/ogg"],
+ [".mov", "video/quicktime"],
+ [".m4v", "video/x-m4v"],
+ [".mp3", "audio/mpeg"],
+ [".wav", "audio/wav"],
+ [".oga", "audio/ogg"],
+ [".m4a", "audio/mp4"],
+ [".aac", "audio/aac"],
+ [".flac", "audio/flac"],
+ [".opus", "audio/opus"],
+ [".pdf", "application/pdf"],
+]);
+
+function isInlinePreviewRequest(req: Request): boolean {
+ return req.query.inline === "1" || req.query.inline === "true";
+}
+
+function getInlinePreviewContentType(filePath: string): string | null {
+ return INLINE_PREVIEW_CONTENT_TYPES.get(extname(filePath).toLowerCase()) ?? null;
+}
+
/**
* Registers task-file, workspace-file, and changed-file routes.
*
@@ -232,32 +272,6 @@ export function registerFileWorkspaceRoutes(ctx: ApiRoutesContext): void {
}
});
- router.get("/files/{*filepath}", async (req, res) => {
- try {
- const { store: scopedStore } = await getProjectContext(req);
- const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
- const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
- ? req.query.workspace
- : "project";
- const result = await readWorkspaceFile(scopedStore, workspace, filePath);
- res.json(result);
- } catch (err: unknown) {
- if (err instanceof ApiError) {
- throw err;
- }
- if (err instanceof FileServiceError) {
- const status = err.code === "ENOTASK" ? 404
- : err.code === "ENOENT" ? 404
- : err.code === "EACCES" ? 403
- : err.code === "ETOOLARGE" ? 413
- : err.code === "EINVAL" && (err instanceof Error ? err.message : String(err)).includes("Binary file") ? 415
- : 400;
- throw new ApiError(status, err.message, { code: err.code });
- }
- rethrowAsApiError(err, "Internal server error");
- }
- });
-
// MUST be before generic wildcard write route.
router.post("/files/{*filepath}/copy", async (req, res) => {
try {
@@ -369,9 +383,21 @@ export function registerFileWorkspaceRoutes(ctx: ApiRoutesContext): void {
const { store: scopedStore } = await getProjectContext(req);
const { filePath, workspace } = extractFileParams(req);
const { absolutePath, stats, fileName } = await getWorkspaceFileForDownload(scopedStore, workspace, filePath);
+ const inlineContentType = isInlinePreviewRequest(req) ? getInlinePreviewContentType(filePath) : null;
- res.setHeader("Content-Type", "application/octet-stream");
- res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
+ /*
+ FNXC:FileBrowser 2026-06-26-00:00:
+ File previews opt into inline rendering and receive a renderable MIME type plus `nosniff` and a sandbox CSP so SVG/markup bytes cannot execute on the dashboard origin. The Download button and unrecognized extensions stay on attachment + octet-stream semantics.
+ */
+ if (inlineContentType) {
+ res.setHeader("Content-Type", inlineContentType);
+ res.setHeader("Content-Disposition", `inline; filename="${fileName}"`);
+ res.setHeader("X-Content-Type-Options", "nosniff");
+ res.setHeader("Content-Security-Policy", "sandbox");
+ } else {
+ res.setHeader("Content-Type", "application/octet-stream");
+ res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
+ }
res.setHeader("Content-Length", stats.size);
res.setHeader("Last-Modified", stats.mtime.toUTCString());
@@ -424,6 +450,33 @@ export function registerFileWorkspaceRoutes(ctx: ApiRoutesContext): void {
}
});
+ // MUST stay after operation routes so suffix routes like /download are not captured as file paths.
+ router.get("/files/{*filepath}", async (req, res) => {
+ try {
+ const { store: scopedStore } = await getProjectContext(req);
+ const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
+ const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
+ ? req.query.workspace
+ : "project";
+ const result = await readWorkspaceFile(scopedStore, workspace, filePath);
+ res.json(result);
+ } catch (err: unknown) {
+ if (err instanceof ApiError) {
+ throw err;
+ }
+ if (err instanceof FileServiceError) {
+ const status = err.code === "ENOTASK" ? 404
+ : err.code === "ENOENT" ? 404
+ : err.code === "EACCES" ? 403
+ : err.code === "ETOOLARGE" ? 413
+ : err.code === "EINVAL" && (err instanceof Error ? err.message : String(err)).includes("Binary file") ? 415
+ : 400;
+ throw new ApiError(status, err.message, { code: err.code });
+ }
+ rethrowAsApiError(err, "Internal server error");
+ }
+ });
+
// MUST be before generic wildcard write route.
router.post("/files/mkdir", async (req, res) => {
try {