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
|
||||
- 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
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
/*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
|
||||
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"));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { 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<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.
|
||||
*
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user