fix(files): report document MIME types for binary files

File reads now carry the document's MIME type for binary files, so a file panel can
hand the bytes to a viewer that needs the real type (application/pdf).
This commit is contained in:
Mohamed Boudra
2026-10-19 13:40:04 +02:00
parent 1c8c68f93e
commit dd18bc9001
2 changed files with 48 additions and 6 deletions
@@ -19,6 +19,8 @@ import {
deleteExplorerEntry,
duplicateExplorerEntry,
getExplorerFileVersion,
getDownloadableFileInfo,
readExplorerFileBytes,
readExplorerFile,
renameExplorerEntry,
streamExplorerFile,
@@ -192,6 +194,36 @@ describe("file explorer service", () => {
}
});
it.each([
["report.pdf", Buffer.from("%PDF-1.4\n%\x00\xff\n%%EOF", "binary")],
["report.PDF", Buffer.from("%PDF-1.4\n%%EOF")],
])("reports application/pdf for %s across file reads and downloads", async (name, bytes) => {
const root = await createTempDir("paseo-file-pdf-");
try {
await writeFile(path.join(root, name), bytes);
const target = { root, relativePath: name };
expect(await readExplorerFile(target)).toMatchObject({
kind: "binary",
mimeType: "application/pdf",
});
expect(await readExplorerFileBytes(target)).toMatchObject({
kind: "binary",
mimeType: "application/pdf",
bytes,
});
await streamExplorerFile(target, async (file) => {
expect(file.kind).toBe("binary");
expect(file.mimeType).toBe("application/pdf");
const chunks: Uint8Array[] = [];
for await (const chunk of file.chunks) chunks.push(chunk);
expect(Buffer.concat(chunks)).toEqual(bytes);
});
expect(await getDownloadableFileInfo(target)).toMatchObject({ mimeType: "application/pdf" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("classifies files with null bytes as binary", async () => {
const root = await createTempDir("paseo-file-explorer-");
@@ -92,6 +92,15 @@ const TEXT_MIME_TYPES: Record<string, string> = {
".json": "application/json",
};
const BINARY_MIME_TYPES: Record<string, string> = {
".pdf": "application/pdf",
".zip": "application/zip",
".gz": "application/gzip",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".woff2": "font/woff2",
};
const DEFAULT_TEXT_MIME_TYPE = "text/plain";
const FILE_TYPE_SAMPLE_BYTES = 8192;
export const FILE_EXPLORER_STREAM_CHUNK_BYTES = 256 * 1024;
@@ -266,13 +275,13 @@ export async function readExplorerFileBytes({
};
}
if (isLikelyBinary(buffer) || !isValidUtf8(buffer)) {
if (ext in BINARY_MIME_TYPES || isLikelyBinary(buffer) || !isValidUtf8(buffer)) {
return {
...basePayload,
kind: "binary",
encoding: "binary",
bytes: buffer,
mimeType: "application/octet-stream",
mimeType: BINARY_MIME_TYPES[ext] ?? "application/octet-stream",
};
}
@@ -305,7 +314,8 @@ export async function streamExplorerFile(
const advertisedRevision = fileRevision(stats);
const ext = path.extname(filePath.resolvedPath).toLowerCase();
const isImage = ext in IMAGE_MIME_TYPES;
const isBinary = isImage || (await isFileHandleBinary(handle, advertisedSize));
const isBinary =
isImage || ext in BINARY_MIME_TYPES || (await isFileHandleBinary(handle, advertisedSize));
let kind: ExplorerFileKind = "text";
let mimeType = textMimeTypeForExtension(ext);
if (isImage) {
@@ -313,7 +323,7 @@ export async function streamExplorerFile(
mimeType = IMAGE_MIME_TYPES[ext];
} else if (isBinary) {
kind = "binary";
mimeType = "application/octet-stream";
mimeType = BINARY_MIME_TYPES[ext] ?? "application/octet-stream";
}
await consume({
@@ -549,10 +559,10 @@ export async function getDownloadableFileInfo({ root, relativePath }: ReadFilePa
}
const ext = path.extname(filePath.resolvedPath).toLowerCase();
let mimeType = "application/octet-stream";
let mimeType = BINARY_MIME_TYPES[ext] ?? "application/octet-stream";
if (ext in IMAGE_MIME_TYPES) {
mimeType = IMAGE_MIME_TYPES[ext];
} else {
} else if (!(ext in BINARY_MIME_TYPES)) {
const sample = Buffer.alloc(FILE_TYPE_SAMPLE_BYTES);
const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
const chunk = bytesRead < sample.length ? sample.subarray(0, bytesRead) : sample;