feat: add file browser image previews

This commit is contained in:
Federico Jaramillo Martinez
2026-05-22 15:36:24 +02:00
parent 5bc25424d3
commit a1e903f8f9
14 changed files with 224 additions and 12 deletions
+37 -1
View File
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { FastifyInstance } from "fastify";
@@ -7,6 +7,7 @@ import { buildApp } from "./app.js";
import { ProjectService } from "./projects/projectService.js";
import { ProjectStore } from "./storage/projectStore.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { Project, Workspace } from "./types.js";
let app: FastifyInstance;
@@ -109,4 +110,39 @@ describe("buildApp", () => {
}),
]);
});
it("serves supported workspace images as previews", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Images", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const svg = "<svg xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"1\" height=\"1\" /></svg>";
await writeFile(join(projectDir, "diagram.svg"), svg);
await writeFile(join(projectDir, "note.txt"), "hello");
await writeFile(join(projectDir, "huge.png"), "");
await truncate(join(projectDir, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1);
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const previewResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("diagram.svg")}` });
expect(previewResponse.statusCode).toBe(200);
expect(previewResponse.headers["content-type"]).toContain("image/svg+xml");
expect(previewResponse.headers["cache-control"]).toBe("private, max-age=3600");
expect(previewResponse.headers["content-security-policy"]).toContain("sandbox");
expect(previewResponse.headers["x-content-type-options"]).toBe("nosniff");
expect(previewResponse.body).toBe(svg);
const rejectedResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("note.txt")}` });
expect(rejectedResponse.statusCode).toBe(400);
expect(rejectedResponse.json()).toEqual({ error: "Image preview is not supported for this file type" });
const tooLargeResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("huge.png")}` });
expect(tooLargeResponse.statusCode).toBe(400);
expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" });
});
});
+18
View File
@@ -4,6 +4,7 @@ import type { WorkspaceService } from "./workspaces/workspaceService.js";
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void {
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/tree", async (request, reply) => {
@@ -23,4 +24,21 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file/preview", async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
const preview = await readWorkspaceImagePreview(context.root, request.query.path);
return await reply
.type(preview.mimeType)
.header("Cache-Control", "private, max-age=3600")
.header("Content-Length", String(preview.size))
.header("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'")
.header("Last-Modified", new Date(preview.modifiedAt).toUTCString())
.header("X-Content-Type-Options", "nosniff")
.send(preview.stream);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
}
@@ -1,8 +1,10 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, rm, truncate, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js";
import { readWorkspaceFile } from "./fileContentService.js";
import { readWorkspaceImagePreview } from "./imagePreviewService.js";
const roots: string[] = [];
@@ -57,6 +59,31 @@ describe("readWorkspaceFile", () => {
expect(file.size).toBe(4);
});
it("marks supported images as previewable", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "logo.PNG"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]));
const file = await readWorkspaceFile(root, "logo.PNG");
expect(file).toMatchObject({ mediaType: "image", mimeType: "image/png", content: "", binary: true, truncated: false });
expect(file.size).toBe(9);
});
it("opens image preview streams only for supported images within the preview size limit", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "diagram.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>");
await writeFile(join(root, "note.txt"), "hello");
await writeFile(join(root, "huge.png"), "");
await truncate(join(root, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1);
const preview = await readWorkspaceImagePreview(root, "diagram.svg");
preview.stream.destroy();
expect(preview).toMatchObject({ path: "diagram.svg", mimeType: "image/svg+xml", size: 46 });
await expect(readWorkspaceImagePreview(root, "note.txt")).rejects.toThrow("Image preview is not supported");
await expect(readWorkspaceImagePreview(root, "huge.png")).rejects.toThrow("Image is too large to preview");
});
it("truncates large text files", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "large.md"), "a".repeat(512 * 1024 + 7));
+23 -3
View File
@@ -1,5 +1,6 @@
import { readFile, stat } from "node:fs/promises";
import { open, stat } from "node:fs/promises";
import type { FileContentResponse } from "../../shared/apiTypes.js";
import { imageMimeTypeForPath } from "./imagePreviewService.js";
import { resolveInsideWorkspace } from "./pathSafety.js";
const MAX_BYTES = 512 * 1024;
@@ -10,11 +11,13 @@ export async function readWorkspaceFile(rootPath: string, path: string | undefin
const s = await stat(target);
if (!s.isFile()) throw new Error("Path is not a file");
const bytesToRead = Math.min(s.size, MAX_BYTES);
const buffer = (await readFile(target)).subarray(0, bytesToRead);
const binary = isProbablyBinary(buffer);
const buffer = await readFilePrefix(target, bytesToRead);
const media = mediaForPath(relativePath);
const binary = media.mediaType === "image" || isProbablyBinary(buffer);
return {
path: relativePath,
...languageForPath(relativePath),
...media,
encoding: "utf8",
size: s.size,
modifiedAt: s.mtime.toISOString(),
@@ -24,6 +27,18 @@ export async function readWorkspaceFile(rootPath: string, path: string | undefin
};
}
async function readFilePrefix(target: string, bytesToRead: number): Promise<Buffer> {
if (bytesToRead === 0) return Buffer.alloc(0);
const buffer = Buffer.alloc(bytesToRead);
const handle = await open(target, "r");
try {
const result = await handle.read(buffer, 0, bytesToRead, 0);
return buffer.subarray(0, result.bytesRead);
} finally {
await handle.close();
}
}
function isProbablyBinary(buffer: Buffer): boolean {
const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
return sample.includes(0);
@@ -50,3 +65,8 @@ function languageForPath(path: string): { language?: string } {
const language = ext === undefined ? undefined : languages[ext];
return language === undefined ? {} : { language };
}
function mediaForPath(path: string): { mediaType?: "image"; mimeType?: string } {
const mimeType = imageMimeTypeForPath(path);
return mimeType === undefined ? {} : { mediaType: "image", mimeType };
}
@@ -0,0 +1,46 @@
import { createReadStream, type ReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { extname } from "node:path";
import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../shared/workspaceFiles.js";
import { resolveInsideWorkspace } from "./pathSafety.js";
const IMAGE_MIME_TYPES: Record<string, string | undefined> = {
".avif": "image/avif",
".bmp": "image/bmp",
".gif": "image/gif",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
};
export interface WorkspaceImagePreview {
path: string;
mimeType: string;
size: number;
modifiedAt: string;
stream: ReadStream;
}
export function imageMimeTypeForPath(path: string): string | undefined {
return IMAGE_MIME_TYPES[extname(path).toLowerCase()];
}
export async function readWorkspaceImagePreview(rootPath: string, path: string | undefined): Promise<WorkspaceImagePreview> {
if (path === undefined || path === "") throw new Error("path query parameter is required");
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
const s = await stat(target);
if (!s.isFile()) throw new Error("Path is not a file");
const mimeType = imageMimeTypeForPath(relativePath);
if (mimeType === undefined) throw new Error("Image preview is not supported for this file type");
if (s.size > MAX_IMAGE_PREVIEW_BYTES) throw new Error(`Image is too large to preview (limit ${MAX_IMAGE_PREVIEW_LABEL})`);
return {
path: relativePath,
mimeType,
size: s.size,
modifiedAt: s.mtime.toISOString(),
stream: createReadStream(target),
};
}