From dd18bc9001533640ed3f391e8ded7225be448934 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 19 Oct 2026 13:39:47 +0200 Subject: [PATCH] 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). --- .../src/server/file-explorer/service.test.ts | 32 +++++++++++++++++++ .../src/server/file-explorer/service.ts | 22 +++++++++---- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index a82e1a8f80..4ba4b57c54 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -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-"); diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index d99c97616b..a4771c2e7a 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -92,6 +92,15 @@ const TEXT_MIME_TYPES: Record = { ".json": "application/json", }; +const BINARY_MIME_TYPES: Record = { + ".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;