mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-01 09:55:34 +08:00
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 <something@gmail.com>
This commit is contained in:
@@ -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) => {
|
app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { projectName } = req.params;
|
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' });
|
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;
|
const normalizedRoot = path.resolve(projectRoot) + path.sep;
|
||||||
if (!resolved.startsWith(normalizedRoot)) {
|
if (!resolved.startsWith(normalizedRoot)) {
|
||||||
return res.status(403).json({ error: 'Path must be under project root' });
|
return res.status(403).json({ error: 'Path must be under project root' });
|
||||||
|
|||||||
@@ -248,6 +248,20 @@ export function useFileTreeOperations({
|
|||||||
showToast(t('fileTree.toast.pathCopied', 'Path copied to clipboard'), 'success');
|
showToast(t('fileTree.toast.pathCopied', 'Path copied to clipboard'), 'success');
|
||||||
}, [showToast, t]);
|
}, [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
|
// Download file or folder
|
||||||
const handleDownload = useCallback(async (item: FileTreeNode) => {
|
const handleDownload = useCallback(async (item: FileTreeNode) => {
|
||||||
if (!selectedProject) return;
|
if (!selectedProject) return;
|
||||||
@@ -272,28 +286,16 @@ export function useFileTreeOperations({
|
|||||||
const downloadSingleFile = useCallback(async (item: FileTreeNode) => {
|
const downloadSingleFile = useCallback(async (item: FileTreeNode) => {
|
||||||
if (!selectedProject) return;
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to download file');
|
throw new Error('Failed to download file');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const blob = await response.blob();
|
||||||
const content = data.content;
|
triggerBrowserDownload(blob, item.name);
|
||||||
|
}, [selectedProject, triggerBrowserDownload]);
|
||||||
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]);
|
|
||||||
|
|
||||||
// Download folder as ZIP
|
// Download folder as ZIP
|
||||||
const downloadFolderAsZip = useCallback(async (folder: FileTreeNode) => {
|
const downloadFolderAsZip = useCallback(async (folder: FileTreeNode) => {
|
||||||
@@ -306,12 +308,14 @@ export function useFileTreeOperations({
|
|||||||
const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name;
|
const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name;
|
||||||
|
|
||||||
if (node.type === 'file') {
|
if (node.type === 'file') {
|
||||||
// Fetch file content
|
const response = await api.readFileBlob(selectedProject.name, node.path);
|
||||||
const response = await api.readFile(selectedProject.name, node.path);
|
if (!response.ok) {
|
||||||
if (response.ok) {
|
throw new Error(`Failed to download "${node.name}" for ZIP export`);
|
||||||
const data = await response.json();
|
|
||||||
zip.file(fullPath, data.content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
} else if (node.type === 'directory' && node.children) {
|
||||||
// Recursively process children
|
// Recursively process children
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
@@ -329,20 +333,10 @@ export function useFileTreeOperations({
|
|||||||
|
|
||||||
// Generate ZIP file
|
// Generate ZIP file
|
||||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||||
const url = URL.createObjectURL(zipBlob);
|
triggerBrowserDownload(zipBlob, `${folder.name}.zip`);
|
||||||
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);
|
|
||||||
|
|
||||||
showToast(t('fileTree.toast.folderDownloaded', 'Folder downloaded as ZIP'), 'success');
|
showToast(t('fileTree.toast.folderDownloaded', 'Folder downloaded as ZIP'), 'success');
|
||||||
}, [selectedProject, showToast, t]);
|
}, [selectedProject, showToast, t, triggerBrowserDownload]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Rename operations
|
// Rename operations
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
readFile: (projectName, filePath) =>
|
readFile: (projectName, filePath) =>
|
||||||
authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(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) =>
|
saveFile: (projectName, filePath, content) =>
|
||||||
authenticatedFetch(`/api/projects/${projectName}/file`, {
|
authenticatedFetch(`/api/projects/${projectName}/file`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -242,4 +244,4 @@ export const api = {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
...options,
|
...options,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user