Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ app.locals.wss = wss;

app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
app.use(express.json({
limit: '50mb',
limit: '200mb',
type: (req) => {
// Skip multipart/form-data requests (for file uploads like images)
const contentType = req.headers['content-type'] || '';
Expand All @@ -135,7 +135,7 @@ app.use(express.json({
return contentType.includes('json');
}
}));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
app.use(express.urlencoded({ limit: '200mb', extended: true }));

// Public health check endpoint (no authentication required)
app.get('/health', (req, res) => {
Expand Down Expand Up @@ -881,7 +881,7 @@ const uploadFilesHandler = async (req, res) => {
}
}),
limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit
fileSize: 200 * 1024 * 1024, // 200MB limit
files: 20 // Max 20 files at once
}
Comment on lines +884 to 886
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

200MB × 20 files allows multi-GB requests and temp-disk exhaustion.

Line 884 with Line 885 now permits up to ~4GB per request in os.tmpdir(). That is a high outage risk under concurrent uploads.

Suggested adjustment
-        limits: {
-            fileSize: 200 * 1024 * 1024, // 200MB limit
-            files: 20 // Max 20 files at once
-        }
+        limits: {
+            fileSize: 200 * 1024 * 1024, // 200MB per file
+            files: 5 // Keep worst-case request size bounded
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fileSize: 200 * 1024 * 1024, // 200MB limit
files: 20 // Max 20 files at once
}
fileSize: 200 * 1024 * 1024, // 200MB per file
files: 5 // Keep worst-case request size bounded
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/index.js` around lines 884 - 886, The current upload config sets
fileSize: 200 * 1024 * 1024 and files: 20 which allows ~4GB of temporary data in
os.tmpdir() per request and risks disk exhaustion; reduce per-file and
per-request limits and add a total-request-size guard: lower fileSize to a safer
value (e.g. 10–50MB) and files to a reasonable concurrent count (e.g. 5), and
implement a check that sums incoming file sizes (or use a library option like
maxTotalFileSize if available) and immediately reject requests when the
aggregate exceeds the threshold; also consider streaming uploads off tmp (use
file write stream handlers or push directly to object storage) and ensure
cleanup on errors. Reference the fileSize and files options and os.tmpdir() in
your changes.

});
Expand All @@ -891,7 +891,7 @@ const uploadFilesHandler = async (req, res) => {
if (err) {
console.error('Multer error:', err);
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
return res.status(400).json({ error: 'File too large. Maximum size is 200MB.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
Expand Down
4 changes: 2 additions & 2 deletions server/modules/websocket/services/shell-websocket.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type PtySessionEntry = {
};

const ptySessionsMap = new Map<string, PtySessionEntry>();
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
const PTY_SESSION_TIMEOUT = 240 * 60 * 1000;
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;

type ShellWebSocketDependencies = {
Expand Down Expand Up @@ -289,7 +289,7 @@ export function handleShellConnection(
return;
}

if (session.buffer.length < 5000) {
if (session.buffer.length < 100000) {
session.buffer.push(chunk);
} else {
session.buffer.shift();
Expand Down
149 changes: 106 additions & 43 deletions src/components/file-tree/hooks/useFileTreeUpload.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useCallback, useState, useRef } from 'react';
import type { Project } from '../../../types/app';
import { api } from '../../../utils/api';

type UseFileTreeUploadOptions = {
selectedProject: Project | null;
Expand Down Expand Up @@ -57,6 +56,45 @@ const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry,
return files;
};

// Shared upload logic using XMLHttpRequest for progress tracking
const uploadFilesWithProgress = (
url: string,
formData: FormData,
token: string | null,
onProgress: (percent: number) => void,
): Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);

if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}

xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
}
};

xhr.onload = () => {
const response = {
ok: xhr.status >= 200 && xhr.status < 300,
status: xhr.status,
json: () => Promise.resolve(JSON.parse(xhr.responseText)),
};
resolve(response);
};

xhr.onerror = () => {
reject(new Error('Network error during upload'));
};

xhr.send(formData);
});
};

export const useFileTreeUpload = ({
selectedProject,
onRefresh,
Expand All @@ -65,8 +103,65 @@ export const useFileTreeUpload = ({
const [isDragOver, setIsDragOver] = useState(false);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [operationLoading, setOperationLoading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const treeRef = useRef<HTMLDivElement>(null);

const performUpload = useCallback(async (files: File[], targetPath: string) => {
if (files.length === 0) {
return;
}

if (!selectedProject) {
showToast('No project selected', 'error');
return;
}

setOperationLoading(true);
setUploadProgress(0);

try {
const formData = new FormData();
formData.append('targetPath', targetPath);

const relativePaths: string[] = [];
files.forEach((file) => {
const cleanFile = new File([file], file.name.split('/').pop()!, {
type: file.type,
lastModified: file.lastModified,
});
formData.append('files', cleanFile);
relativePaths.push(file.name);
});

formData.append('relativePaths', JSON.stringify(relativePaths));

const token = localStorage.getItem('auth-token');
const url = `/api/projects/${encodeURIComponent(selectedProject.projectId)}/files/upload`;

const response = await uploadFilesWithProgress(
url,
formData,
token,
(percent) => setUploadProgress(percent),
);

if (!response.ok) {
const data = (await response.json()) as { error?: string };
throw new Error(data.error || 'Upload failed');
}

showToast(`Uploaded ${files.length} file(s)`, 'success');
onRefresh();
} catch (err) {
console.error('Upload error:', err);
showToast(err instanceof Error ? err.message : 'Upload failed', 'error');
} finally {
setOperationLoading(false);
setUploadProgress(0);
setDropTarget(null);
}
}, [selectedProject, onRefresh, showToast]);
Comment on lines +109 to +163
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard performUpload against concurrent execution.

performUpload can be entered again (e.g., drag/drop during an active upload), which races shared loading/progress state and can trigger overlapping uploads.

Suggested adjustment
   const [operationLoading, setOperationLoading] = useState(false);
   const [uploadProgress, setUploadProgress] = useState(0);
+  const uploadInFlightRef = useRef(false);
   const treeRef = useRef<HTMLDivElement>(null);

   const performUpload = useCallback(async (files: File[], targetPath: string) => {
+    if (uploadInFlightRef.current) {
+      showToast('Upload already in progress', 'error');
+      return;
+    }
     if (files.length === 0) {
       return;
     }

     if (!selectedProject) {
       showToast('No project selected', 'error');
       return;
     }

+    uploadInFlightRef.current = true;
     setOperationLoading(true);
     setUploadProgress(0);

     try {
       // ...
     } finally {
       setOperationLoading(false);
       setUploadProgress(0);
       setDropTarget(null);
+      uploadInFlightRef.current = false;
     }
   }, [selectedProject, onRefresh, showToast]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/file-tree/hooks/useFileTreeUpload.ts` around lines 109 - 163,
performUpload can run concurrently and clobber shared state; add a simple guard
(preferably an isUploadingRef via useRef<boolean>(false) or check an existing
operationLoading state) at the top of performUpload to early-return if an upload
is in progress, set the guard true immediately before starting the upload, and
clear it in the finally block alongside the existing
setOperationLoading(false)/setUploadProgress(0)/setDropTarget(null); ensure the
guard is referenced in performUpload (and included in its dependency list if you
use a ref or memoized callback) so overlapping drag/drop or repeated calls
cannot start a second upload while one is active.


const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
Expand Down Expand Up @@ -94,7 +189,6 @@ export const useFileTreeUpload = ({
setIsDragOver(false);

const targetPath = dropTarget || '';
setOperationLoading(true);

try {
const files: File[] = [];
Expand Down Expand Up @@ -129,54 +223,21 @@ export const useFileTreeUpload = ({
}

if (files.length === 0) {
setOperationLoading(false);
setDropTarget(null);
return;
}

const formData = new FormData();
formData.append('targetPath', targetPath);

// Store relative paths separately since FormData strips path info from File.name
const relativePaths: string[] = [];
files.forEach((file) => {
// Create a new file with just the filename (without path) for FormData
// but store the relative path separately
const cleanFile = new File([file], file.name.split('/').pop()!, {
type: file.type,
lastModified: file.lastModified
});
formData.append('files', cleanFile);
relativePaths.push(file.name); // Keep the full relative path
});

// Send relative paths as a JSON array
formData.append('relativePaths', JSON.stringify(relativePaths));

const response = await api.post(
// File upload endpoint is keyed by DB projectId post-migration.
`/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`,
formData
);

if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}

showToast(
`Uploaded ${files.length} file(s)`,
'success'
);
onRefresh();
await performUpload(files, targetPath);
} catch (err) {
console.error('Upload error:', err);
console.error('Drop error:', err);
showToast(err instanceof Error ? err.message : 'Upload failed', 'error');
} finally {
setOperationLoading(false);
setDropTarget(null);
}
}, [dropTarget, selectedProject, onRefresh, showToast]);
}, [dropTarget, performUpload, showToast]);

// Handle file selection from the upload button's file input
const handleFileSelect = useCallback((files: File[], targetPath?: string) => {
performUpload(files, targetPath || '');
}, [performUpload]);

const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => {
e.preventDefault();
Expand All @@ -194,11 +255,13 @@ export const useFileTreeUpload = ({
isDragOver,
dropTarget,
operationLoading,
uploadProgress,
treeRef,
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
handleFileSelect,
handleItemDragOver,
handleItemDrop,
setDropTarget,
Expand Down
18 changes: 17 additions & 1 deletion src/components/file-tree/view/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,11 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
onSearchQueryChange={setSearchQuery}
onNewFile={() => operations.handleStartCreate('', 'file')}
onNewFolder={() => operations.handleStartCreate('', 'directory')}
onUpload={(files) => upload.handleFileSelect(files)}
onRefresh={refreshFiles}
onCollapseAll={collapseAll}
loading={loading}
operationLoading={operations.operationLoading}
operationLoading={operations.operationLoading || upload.operationLoading}
/>

{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
Expand Down Expand Up @@ -217,6 +218,21 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
/>
</ScrollArea>

{/* Upload progress bar */}
{upload.uploadProgress > 0 && upload.uploadProgress < 100 && (
<div className="px-3 pb-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-blue-500 transition-all duration-300 ease-out"
style={{ width: `${upload.uploadProgress}%` }}
/>
</div>
<p className="mt-1 text-center text-xs text-muted-foreground">
{t('fileTree.uploading', 'Uploading... {{progress}}%', { progress: upload.uploadProgress })}
</p>
</div>
)}

{selectedImage && (
<ImageViewer
file={selectedImage}
Expand Down
43 changes: 42 additions & 1 deletion src/components/file-tree/view/FileTreeHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react';
import { useCallback, useRef } from 'react';
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, Upload, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../shared/view/ui';
import { cn } from '../../../lib/utils';
Expand All @@ -12,6 +13,7 @@ type FileTreeHeaderProps = {
// Toolbar actions
onNewFile?: () => void;
onNewFolder?: () => void;
onUpload?: (files: File[]) => void;
onRefresh?: () => void;
onCollapseAll?: () => void;
// Loading state
Expand All @@ -26,12 +28,29 @@ export default function FileTreeHeader({
onSearchQueryChange,
onNewFile,
onNewFolder,
onUpload,
onRefresh,
onCollapseAll,
loading,
operationLoading,
}: FileTreeHeaderProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);

const handleUploadClick = useCallback(() => {
fileInputRef.current?.click();
}, []);

const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const fileList = e.target.files;
if (fileList && fileList.length > 0 && onUpload) {
onUpload(Array.from(fileList));
}
// Reset input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [onUpload]);

return (
<div className="space-y-2 border-b border-border px-3 pb-2 pt-3">
Expand Down Expand Up @@ -66,6 +85,28 @@ export default function FileTreeHeader({
<FolderPlus className="h-3.5 w-3.5" />
</Button>
)}
{onUpload && (
<>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
/>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={handleUploadClick}
title={t('fileTree.upload', 'Upload Files')}
aria-label={t('fileTree.upload', 'Upload Files')}
disabled={operationLoading}
>
<Upload className="h-3.5 w-3.5" />
</Button>
</>
)}
{onRefresh && (
<Button
variant="ghost"
Expand Down