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 {