FN-7073: serve file previews inline
File viewer previews now request inline-safe responses while downloads remain attachments. - Add an inline preview query option to file download URL helpers and preview consumers. - Serve known media and PDF preview extensions with real MIME types, inline disposition, nosniff, and sandbox CSP headers. - Keep explicit downloads and unknown file types on attachment/octet-stream behavior. - Cover preview and download header behavior with dashboard route and component tests. Files changed: .changeset/fn-7073-file-viewer-inline-preview.md | 7 + docs/dashboard-guide.md | 2 +- packages/dashboard/app/api/legacy.ts | 9 +- .../dashboard/app/components/DockFilesView.tsx | 2 +- .../dashboard/app/components/FileBrowserModal.tsx | 2 +- .../components/__tests__/DockFilesView.test.tsx | 14 +- .../components/__tests__/FileBrowserModal.test.tsx | 12 +- .../register-file-workspace-routes.test.ts | 141 +++++++++++++++++++++ .../src/routes/register-file-workspace-routes.ts | 111 +++++++++++----- 9 files changed, 260 insertions(+), 40 deletions(-) Fusion-Task-Id: FN-7073 Fusion-Task-Lineage: 922b4aa4-ac6d-4a59-b94f-bab4eeb65530
This commit is contained in:
7
.changeset/fn-7073-file-viewer-inline-preview.md
Normal file
7
.changeset/fn-7073-file-viewer-inline-preview.md
Normal file
@@ -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.
|
||||||
@@ -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
|
- 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
|
- 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
|
- 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
|
- 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
|
## Memory View
|
||||||
|
|||||||
@@ -3511,11 +3511,18 @@ export function renameFile(workspace: string, filePath: string, newName: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Get the download URL for a single file in a workspace. */
|
/** 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 });
|
const query = new URLSearchParams({ workspace });
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
query.set("projectId", 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()}`;
|
return `/api/files/${encodeURIComponent(filePath)}/download?${query.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
|
|||||||
if (!selectedFile || !selectedPreviewKind) {
|
if (!selectedFile || !selectedPreviewKind) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return downloadFileUrl("project", selectedFile, projectId);
|
return downloadFileUrl("project", selectedFile, projectId, { inline: true });
|
||||||
}, [projectId, selectedFile, selectedPreviewKind]);
|
}, [projectId, selectedFile, selectedPreviewKind]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ export function FileBrowserModal({
|
|||||||
*/
|
*/
|
||||||
const previewUrl = useMemo(() => {
|
const previewUrl = useMemo(() => {
|
||||||
if (!selectedFile || !selectedPreviewKind) return null;
|
if (!selectedFile || !selectedPreviewKind) return null;
|
||||||
return downloadFileUrl(currentWorkspace, selectedFile, projectId);
|
return downloadFileUrl(currentWorkspace, selectedFile, projectId, { inline: true });
|
||||||
}, [currentWorkspace, projectId, selectedFile, selectedPreviewKind]);
|
}, [currentWorkspace, projectId, selectedFile, selectedPreviewKind]);
|
||||||
|
|
||||||
const selectedPreviewLabel = selectedFile
|
const selectedPreviewLabel = selectedFile
|
||||||
|
|||||||
@@ -47,15 +47,16 @@ vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
|
|||||||
|
|
||||||
const mockFetchContent = vi.fn(() => Promise.resolve({ content: "# hi" }));
|
const mockFetchContent = vi.fn(() => Promise.resolve({ content: "# hi" }));
|
||||||
const mockSaveContent = vi.fn(() => Promise.resolve({ mtime: "2026-01-15T10:31:00Z" }));
|
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 });
|
const params = new URLSearchParams({ workspace });
|
||||||
if (projectId) params.set("projectId", projectId);
|
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", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchWorkspaceFileContent: (...args: unknown[]) => mockFetchContent(...(args as [])),
|
fetchWorkspaceFileContent: (...args: unknown[]) => mockFetchContent(...(args as [])),
|
||||||
saveWorkspaceFileContent: (...args: unknown[]) => mockSaveContent(...(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<{
|
const capturedEditorHookCalls: Array<{
|
||||||
@@ -284,7 +285,7 @@ describe("DockFilesView shared current-file state", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(document.querySelector(selector)).toBeInTheDocument());
|
await waitFor(() => expect(document.querySelector(selector)).toBeInTheDocument());
|
||||||
const preview = document.querySelector(selector);
|
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")) {
|
if (selector.startsWith("video") || selector.startsWith("audio")) {
|
||||||
expect(preview).toHaveAttribute("controls");
|
expect(preview).toHaveAttribute("controls");
|
||||||
expect(preview).toHaveAttribute("aria-label", `Preview for ${file}`);
|
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("mock-file-editor")).toBeNull();
|
||||||
expect(screen.queryByTestId("right-dock-files-save")).toBeNull();
|
expect(screen.queryByTestId("right-dock-files-save")).toBeNull();
|
||||||
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: file, enabled: false, projectId: PROJECT_ID });
|
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 () => {
|
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(screen.getByTestId("right-dock-files-save")).toBeEnabled();
|
||||||
expect(document.querySelector(".file-browser-preview")).not.toBeInTheDocument();
|
expect(document.querySelector(".file-browser-preview")).not.toBeInTheDocument();
|
||||||
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: "changed.txt", enabled: true, projectId: PROJECT_ID });
|
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", () => {
|
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(screen.queryByTestId("right-dock-files-save")).toBeNull();
|
||||||
expect(document.querySelector(".file-browser-preview")).not.toBeInTheDocument();
|
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(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 () => {
|
it("clears stale preview state when switching from a preview file back to text", async () => {
|
||||||
render(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
|
render(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
|
||||||
fireEvent.click(screen.getByText("assets/Logo.PNG"));
|
fireEvent.click(screen.getByText("assets/Logo.PNG"));
|
||||||
await waitFor(() => expect(document.querySelector("img.file-browser-preview-media--image")).toBeInTheDocument());
|
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"));
|
fireEvent.click(screen.getByText("readme.md"));
|
||||||
await waitFor(() => expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md"));
|
await waitFor(() => expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md"));
|
||||||
|
|||||||
@@ -825,6 +825,7 @@ describe("FileBrowserModal", () => {
|
|||||||
expect(preview).toBeInTheDocument();
|
expect(preview).toBeInTheDocument();
|
||||||
expect(preview).toHaveAttribute(attribute, expect.stringContaining(encodeURIComponent(name)));
|
expect(preview).toHaveAttribute(attribute, expect.stringContaining(encodeURIComponent(name)));
|
||||||
expect(preview).toHaveAttribute(attribute, expect.stringContaining("workspace=project"));
|
expect(preview).toHaveAttribute(attribute, expect.stringContaining("workspace=project"));
|
||||||
|
expect(preview).toHaveAttribute(attribute, expect.stringContaining("inline=1"));
|
||||||
if (selector.startsWith("video") || selector.startsWith("audio")) {
|
if (selector.startsWith("video") || selector.startsWith("audio")) {
|
||||||
expect(preview).toHaveAttribute("controls");
|
expect(preview).toHaveAttribute("controls");
|
||||||
expect(preview).toHaveAttribute("aria-label", `Preview for ${name}`);
|
expect(preview).toHaveAttribute("aria-label", `Preview for ${name}`);
|
||||||
@@ -858,6 +859,7 @@ describe("FileBrowserModal", () => {
|
|||||||
expect(video).toBeInTheDocument();
|
expect(video).toBeInTheDocument();
|
||||||
expect(video).toHaveAttribute("src", expect.stringContaining("workspace=FN-001"));
|
expect(video).toHaveAttribute("src", expect.stringContaining("workspace=FN-001"));
|
||||||
expect(video).toHaveAttribute("src", expect.stringContaining("projectId=proj-1"));
|
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(video).toHaveAttribute("src", expect.stringContaining("movie.mov"));
|
||||||
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-001", "movie.mov", false, "proj-1");
|
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());
|
await waitFor(() => expect(document.querySelector("iframe.file-browser-preview-media--pdf")).toBeInTheDocument());
|
||||||
const pdf = document.querySelector("iframe.file-browser-preview-media--pdf");
|
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(encodeURIComponent("docs/MANUAL.PDF")));
|
||||||
|
expect(pdf).toHaveAttribute("src", expect.stringContaining("inline=1"));
|
||||||
expect(pdf).toHaveAttribute("title", "Preview for docs/MANUAL.PDF");
|
expect(pdf).toHaveAttribute("title", "Preview for docs/MANUAL.PDF");
|
||||||
expect(mockSetPath).toHaveBeenCalledWith("docs");
|
expect(mockSetPath).toHaveBeenCalledWith("docs");
|
||||||
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "docs/MANUAL.PDF", false, undefined);
|
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "docs/MANUAL.PDF", false, undefined);
|
||||||
@@ -936,7 +939,10 @@ describe("FileBrowserModal", () => {
|
|||||||
|
|
||||||
await selectFile("clip.mp4");
|
await selectFile("clip.mp4");
|
||||||
expect(document.querySelector("iframe.file-browser-preview-media--pdf")).not.toBeInTheDocument();
|
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);
|
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "clip.mp4", false, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -955,7 +961,9 @@ describe("FileBrowserModal", () => {
|
|||||||
await selectFile("voice.mp3");
|
await selectFile("voice.mp3");
|
||||||
|
|
||||||
expect(screen.getByLabelText("Back to file list")).toBeInTheDocument();
|
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();
|
expect(document.querySelector(".file-browser-content.mobile.active")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "fusion-file-workspace-routes-"));
|
||||||
|
tempRoots.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeApp(store: Partial<TaskStore>) {
|
||||||
|
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<void> {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { access } from "node:fs/promises";
|
import { access } from "node:fs/promises";
|
||||||
import { createReadStream } from "node:fs";
|
import { createReadStream } from "node:fs";
|
||||||
|
import { extname } from "node:path";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import { ApiError, badRequest } from "../api-error.js";
|
import { ApiError, badRequest } from "../api-error.js";
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +34,45 @@ function extractFileParams(req: Request): { filePath: string; workspace: string
|
|||||||
return { filePath, workspace };
|
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<string, string>([
|
||||||
|
[".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.
|
* 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.
|
// MUST be before generic wildcard write route.
|
||||||
router.post("/files/{*filepath}/copy", async (req, res) => {
|
router.post("/files/{*filepath}/copy", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -369,9 +383,21 @@ export function registerFileWorkspaceRoutes(ctx: ApiRoutesContext): void {
|
|||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const { filePath, workspace } = extractFileParams(req);
|
const { filePath, workspace } = extractFileParams(req);
|
||||||
const { absolutePath, stats, fileName } = await getWorkspaceFileForDownload(scopedStore, workspace, filePath);
|
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("Content-Length", stats.size);
|
||||||
res.setHeader("Last-Modified", stats.mtime.toUTCString());
|
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.
|
// MUST be before generic wildcard write route.
|
||||||
router.post("/files/mkdir", async (req, res) => {
|
router.post("/files/mkdir", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user