From e61f8a543d63fe7c24a04b3d2186085a06dcbcdb Mon Sep 17 00:00:00 2001 From: Haile <118998054+blackmammoth@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:35:23 +0300 Subject: [PATCH] fix: corrupted binary downloads (#634) - The existing setup was using the text reader endpoint for downloading files `fsPromises.readFile(..., 'utf8')` at line 801. This was incorrect - In the old Files tab flow, the client then took that decoded string and rebuilt it as a text blob. That UTF-8 decode/re-encode step changes raw bytes, so the downloaded file no longer matches the original. Folder ZIP export had the same problem for any binary file inside the archive. Co-authored-by: Haileyesus --- server/index.js | 8 ++- .../file-tree/hooks/useFileTreeOperations.ts | 62 +++++++++---------- src/utils/api.js | 4 +- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/server/index.js b/server/index.js index 7fa5fe21..235f143b 100755 --- a/server/index.js +++ b/server/index.js @@ -812,7 +812,7 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) = } }); -// Serve binary file content endpoint (for images, etc.) +// Serve raw file bytes for previews and downloads. app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => { try { const { projectName } = req.params; @@ -829,7 +829,11 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re return res.status(404).json({ error: 'Project not found' }); } - const resolved = path.resolve(filePath); + // Match the text reader endpoint so callers can pass either project-relative + // or absolute paths without changing how the bytes are served. + const resolved = path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(projectRoot, filePath); const normalizedRoot = path.resolve(projectRoot) + path.sep; if (!resolved.startsWith(normalizedRoot)) { return res.status(403).json({ error: 'Path must be under project root' }); diff --git a/src/components/file-tree/hooks/useFileTreeOperations.ts b/src/components/file-tree/hooks/useFileTreeOperations.ts index 80f3256c..398fcbe5 100644 --- a/src/components/file-tree/hooks/useFileTreeOperations.ts +++ b/src/components/file-tree/hooks/useFileTreeOperations.ts @@ -248,6 +248,20 @@ export function useFileTreeOperations({ showToast(t('fileTree.toast.pathCopied', 'Path copied to clipboard'), 'success'); }, [showToast, t]); + const triggerBrowserDownload = useCallback((blob: Blob, fileName: string) => { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + + anchor.href = url; + anchor.download = fileName; + + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + + URL.revokeObjectURL(url); + }, []); + // Download file or folder const handleDownload = useCallback(async (item: FileTreeNode) => { if (!selectedProject) return; @@ -272,28 +286,16 @@ export function useFileTreeOperations({ const downloadSingleFile = useCallback(async (item: FileTreeNode) => { if (!selectedProject) return; - const response = await api.readFile(selectedProject.name, item.path); + // Use the binary streaming endpoint so downloads preserve raw bytes. + const response = await api.readFileBlob(selectedProject.name, item.path); if (!response.ok) { throw new Error('Failed to download file'); } - const data = await response.json(); - const content = data.content; - - const blob = new Blob([content], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - - anchor.href = url; - anchor.download = item.name; - - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - - URL.revokeObjectURL(url); - }, [selectedProject]); + const blob = await response.blob(); + triggerBrowserDownload(blob, item.name); + }, [selectedProject, triggerBrowserDownload]); // Download folder as ZIP const downloadFolderAsZip = useCallback(async (folder: FileTreeNode) => { @@ -306,12 +308,14 @@ export function useFileTreeOperations({ const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name; if (node.type === 'file') { - // Fetch file content - const response = await api.readFile(selectedProject.name, node.path); - if (response.ok) { - const data = await response.json(); - zip.file(fullPath, data.content); + const response = await api.readFileBlob(selectedProject.name, node.path); + if (!response.ok) { + throw new Error(`Failed to download "${node.name}" for ZIP export`); } + + // Store raw bytes in the archive so binary files stay intact. + const fileBytes = await response.arrayBuffer(); + zip.file(fullPath, fileBytes); } else if (node.type === 'directory' && node.children) { // Recursively process children for (const child of node.children) { @@ -329,20 +333,10 @@ export function useFileTreeOperations({ // Generate ZIP file const zipBlob = await zip.generateAsync({ type: 'blob' }); - const url = URL.createObjectURL(zipBlob); - const anchor = document.createElement('a'); - - anchor.href = url; - anchor.download = `${folder.name}.zip`; - - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - - URL.revokeObjectURL(url); + triggerBrowserDownload(zipBlob, `${folder.name}.zip`); showToast(t('fileTree.toast.folderDownloaded', 'Folder downloaded as ZIP'), 'success'); - }, [selectedProject, showToast, t]); + }, [selectedProject, showToast, t, triggerBrowserDownload]); return { // Rename operations diff --git a/src/utils/api.js b/src/utils/api.js index a3292b21..7c14a677 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -111,6 +111,8 @@ export const api = { }), readFile: (projectName, filePath) => authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`), + readFileBlob: (projectName, filePath) => + authenticatedFetch(`/api/projects/${projectName}/files/content?path=${encodeURIComponent(filePath)}`), saveFile: (projectName, filePath, content) => authenticatedFetch(`/api/projects/${projectName}/file`, { method: 'PUT', @@ -242,4 +244,4 @@ export const api = { method: 'DELETE', ...options, }), -}; \ No newline at end of file +};