Skip to content

Commit 9ca8168

Browse files
jeremyederAmbient Code Botclaude
authored
feat(frontend): add folder upload support (#1042)
## Summary - Adds a **Folder** tab to the upload modal alongside File and URL - Uploads all files sequentially, preserving directory structure under `file-uploads/` - Validates per-file (10MB) and total folder (100MB) size limits with hard errors - Adds `sanitizeRelativePath` with path traversal protection and depth limit (20 levels) - 13 tests passing (3 new folder validation tests) ## Test plan - [ ] Select a folder with mixed file sizes — all upload correctly - [ ] Select a folder with a file >10MB — hard error shown - [ ] Select a folder totaling >100MB — hard error shown - [ ] Single file and URL upload still work as before 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Ambient Code Bot <bot@ambient-code.local> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 02b2a7d commit 9ca8168

File tree

4 files changed

+374
-38
lines changed

4 files changed

+374
-38
lines changed

components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/upload/route.ts

100644100755
Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ function sanitizeFilename(filename: string): string {
3737
return filename.replace(/[\/\\\0]/g, '_').substring(0, 255);
3838
}
3939

40+
// Sanitize a relative path (e.g. "folder/subfolder/file.txt") for folder uploads.
41+
// Each segment is sanitized individually, then rejoined.
42+
// Prevents path traversal while preserving directory structure.
43+
function sanitizeRelativePath(relativePath: string): string {
44+
const MAX_PATH_DEPTH = 20;
45+
const segments = relativePath
46+
.split('/')
47+
.filter(segment => segment && segment !== '..' && segment !== '.')
48+
.map(segment => segment.replace(/[\\\0]/g, '_').substring(0, 255));
49+
if (segments.length > MAX_PATH_DEPTH) {
50+
throw new Error(`Path too deeply nested (max ${MAX_PATH_DEPTH} levels)`);
51+
}
52+
return segments.join('/');
53+
}
54+
4055
// Validate URL to prevent SSRF attacks
4156
// Returns true if URL is safe to fetch, false otherwise
4257
function isValidUrl(urlString: string): boolean {
@@ -318,14 +333,23 @@ async function uploadFileToWorkspace(
318333
contentType: string,
319334
headers: HeadersInit,
320335
name: string,
321-
sessionName: string
336+
sessionName: string,
337+
subpath?: string
322338
): Promise<Response> {
323339
const maxRetries = 3;
324340
const retryDelay = 2000; // 2 seconds
325341

342+
// Build the upload path: file-uploads/[subpath/]filename
343+
const pathParts = ['file-uploads'];
344+
if (subpath) {
345+
pathParts.push(...subpath.split('/').map(s => encodeURIComponent(s)));
346+
}
347+
pathParts.push(encodeURIComponent(filename));
348+
const uploadPath = pathParts.join('/');
349+
326350
for (let retries = 0; retries < maxRetries; retries++) {
327351
const resp = await fetch(
328-
`${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/workspace/file-uploads/${encodeURIComponent(filename)}`,
352+
`${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/workspace/${uploadPath}`,
329353
{
330354
method: 'PUT',
331355
headers: {
@@ -362,6 +386,20 @@ export async function POST(
362386
const formData = await request.formData();
363387
const uploadType = formData.get('type') as string;
364388

389+
// Optional subpath for folder uploads (preserves directory structure)
390+
const rawSubpath = formData.get('subpath') as string | null;
391+
let subpath: string | undefined;
392+
if (rawSubpath) {
393+
try {
394+
subpath = sanitizeRelativePath(rawSubpath);
395+
} catch {
396+
return new Response(JSON.stringify({ error: 'Invalid upload path: too deeply nested' }), {
397+
status: 400,
398+
headers: { 'Content-Type': 'application/json' },
399+
});
400+
}
401+
}
402+
365403
if (uploadType === 'local') {
366404
// Handle local file upload
367405
const file = formData.get('file') as File;
@@ -416,7 +454,7 @@ export async function POST(
416454
}
417455

418456
// Upload to workspace with retry logic
419-
const resp = await uploadFileToWorkspace(fileBuffer, filename, finalContentType, headers, name, sessionName);
457+
const resp = await uploadFileToWorkspace(fileBuffer, filename, finalContentType, headers, name, sessionName, subpath);
420458

421459
if (!resp.ok) {
422460
const errorText = await resp.text();
@@ -430,7 +468,7 @@ export async function POST(
430468
return new Response(
431469
JSON.stringify({
432470
success: true,
433-
filename,
471+
filename: subpath ? `${subpath}/${filename}` : filename,
434472
compressed: compressionInfo.compressed,
435473
originalSize: compressionInfo.originalSize,
436474
finalSize: compressionInfo.finalSize,
@@ -518,7 +556,7 @@ export async function POST(
518556
}
519557

520558
// Upload to workspace with retry logic
521-
const resp = await uploadFileToWorkspace(fileBuffer, filename, finalContentType, headers, name, sessionName);
559+
const resp = await uploadFileToWorkspace(fileBuffer, filename, finalContentType, headers, name, sessionName, subpath);
522560

523561
if (!resp.ok) {
524562
const errorText = await resp.text();
@@ -532,7 +570,7 @@ export async function POST(
532570
return new Response(
533571
JSON.stringify({
534572
success: true,
535-
filename,
573+
filename: subpath ? `${subpath}/${filename}` : filename,
536574
compressed: compressionInfo.compressed,
537575
originalSize: compressionInfo.originalSize,
538576
finalSize: compressionInfo.finalSize,

components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/__tests__/upload-file-modal.test.tsx

100644100755
Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import React, { createContext, useContext } from 'react';
12
import { describe, it, expect, vi, beforeEach } from 'vitest';
23
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
34
import { UploadFileModal } from '../upload-file-modal';
@@ -10,6 +11,37 @@ vi.mock('@/hooks/use-input-history', () => ({
1011
})),
1112
}));
1213

14+
// Mock Radix Tabs so tab switching works reliably in jsdom
15+
const TabsContext = createContext({ value: '', onValueChange: (() => {}) as (v: string) => void });
16+
17+
vi.mock('@/components/ui/tabs', () => ({
18+
Tabs: ({ children, value, onValueChange, className }: Record<string, unknown>) => (
19+
<TabsContext.Provider value={{ value: value as string, onValueChange: onValueChange as (v: string) => void }}>
20+
<div className={className as string}>{children as React.ReactNode}</div>
21+
</TabsContext.Provider>
22+
),
23+
TabsList: ({ children, className }: Record<string, unknown>) => (
24+
<div role="tablist" className={className as string}>{children as React.ReactNode}</div>
25+
),
26+
TabsTrigger: ({ children, value, disabled }: Record<string, unknown>) => {
27+
const ctx = useContext(TabsContext);
28+
return (
29+
<button
30+
role="tab"
31+
data-state={ctx.value === value ? 'active' : 'inactive'}
32+
disabled={disabled as boolean}
33+
onClick={() => ctx.onValueChange(value as string)}
34+
>
35+
{children as React.ReactNode}
36+
</button>
37+
);
38+
},
39+
TabsContent: ({ children, value, className }: Record<string, unknown>) => {
40+
const ctx = useContext(TabsContext);
41+
return ctx.value === value ? <div role="tabpanel" className={className as string}>{children as React.ReactNode}</div> : null;
42+
},
43+
}));
44+
1345
vi.mock('@/components/input-with-history', () => ({
1446
InputWithHistory: (props: Record<string, unknown>) => (
1547
<input
@@ -39,8 +71,9 @@ describe('UploadFileModal', () => {
3971
it('renders modal when open', () => {
4072
render(<UploadFileModal {...defaultProps} />);
4173
expect(screen.getByText('Upload File')).toBeDefined();
42-
expect(screen.getByText('Local File')).toBeDefined();
43-
expect(screen.getByText('From URL')).toBeDefined();
74+
expect(screen.getByText('File')).toBeDefined();
75+
expect(screen.getByText('Folder')).toBeDefined();
76+
expect(screen.getByText('URL')).toBeDefined();
4477
expect(screen.getByText('Cancel')).toBeDefined();
4578
expect(screen.getByText('Upload')).toBeDefined();
4679
});
@@ -119,7 +152,7 @@ describe('UploadFileModal', () => {
119152

120153
it('switches to URL tab and shows URL input', async () => {
121154
render(<UploadFileModal {...defaultProps} />);
122-
fireEvent.click(screen.getByText('From URL'));
155+
fireEvent.click(screen.getByText('URL'));
123156

124157
// Radix Tabs may not render inactive content in jsdom, but the tab trigger should be active
125158
await waitFor(() => {
@@ -128,8 +161,81 @@ describe('UploadFileModal', () => {
128161
expect(urlInput).toBeDefined();
129162
} else {
130163
// Tab triggers still render
131-
expect(screen.getByText('From URL')).toBeDefined();
164+
expect(screen.getByText('URL')).toBeDefined();
132165
}
133166
});
134167
});
168+
169+
it('switches to Folder tab and shows folder input', async () => {
170+
render(<UploadFileModal {...defaultProps} />);
171+
fireEvent.click(screen.getByText('Folder'));
172+
173+
const folderInput = await screen.findByLabelText('Choose Folder');
174+
expect(folderInput).toBeDefined();
175+
});
176+
177+
it('shows error when folder contains a file exceeding per-file limit', async () => {
178+
render(<UploadFileModal {...defaultProps} />);
179+
fireEvent.click(screen.getByText('Folder'));
180+
181+
const folderInput = await screen.findByLabelText('Choose Folder');
182+
183+
const smallFile = new File(['ok'], 'a.txt', { type: 'text/plain' });
184+
Object.defineProperty(smallFile, 'size', { value: 1024 });
185+
Object.defineProperty(smallFile, 'webkitRelativePath', { value: 'mydir/a.txt' });
186+
187+
const bigFile = new File(['big'], 'big.bin', { type: 'application/octet-stream' });
188+
Object.defineProperty(bigFile, 'size', { value: 11 * 1024 * 1024 });
189+
Object.defineProperty(bigFile, 'webkitRelativePath', { value: 'mydir/big.bin' });
190+
191+
fireEvent.change(folderInput, { target: { files: [smallFile, bigFile] } });
192+
193+
await waitFor(() => {
194+
expect(screen.getByText(/exceeds the per-file limit/)).toBeDefined();
195+
});
196+
});
197+
198+
it('shows error when total folder size exceeds limit', async () => {
199+
render(<UploadFileModal {...defaultProps} />);
200+
fireEvent.click(screen.getByText('Folder'));
201+
202+
const folderInput = await screen.findByLabelText('Choose Folder');
203+
204+
// Create files that together exceed 100MB but individually are under 10MB
205+
const files = [];
206+
for (let i = 0; i < 12; i++) {
207+
const file = new File([`file-${i}`], `file-${i}.bin`, { type: 'application/octet-stream' });
208+
Object.defineProperty(file, 'size', { value: 9 * 1024 * 1024 }); // 9MB each, 12 * 9 = 108MB
209+
Object.defineProperty(file, 'webkitRelativePath', { value: `mydir/file-${i}.bin` });
210+
files.push(file);
211+
}
212+
213+
fireEvent.change(folderInput, { target: { files } });
214+
215+
await waitFor(() => {
216+
expect(screen.getByText(/exceeds the maximum allowed size/)).toBeDefined();
217+
});
218+
});
219+
220+
it('accepts a valid folder and shows summary', async () => {
221+
render(<UploadFileModal {...defaultProps} />);
222+
fireEvent.click(screen.getByText('Folder'));
223+
224+
const folderInput = await screen.findByLabelText('Choose Folder');
225+
226+
const file1 = new File(['hello'], 'a.txt', { type: 'text/plain' });
227+
Object.defineProperty(file1, 'size', { value: 512 });
228+
Object.defineProperty(file1, 'webkitRelativePath', { value: 'mydir/a.txt' });
229+
230+
const file2 = new File(['world'], 'b.txt', { type: 'text/plain' });
231+
Object.defineProperty(file2, 'size', { value: 256 });
232+
Object.defineProperty(file2, 'webkitRelativePath', { value: 'mydir/sub/b.txt' });
233+
234+
fireEvent.change(folderInput, { target: { files: [file1, file2] } });
235+
236+
await waitFor(() => {
237+
expect(screen.getByText(/Selected: mydir\//)).toBeDefined();
238+
expect(screen.getByText(/2 file\(s\)/)).toBeDefined();
239+
});
240+
});
135241
});

0 commit comments

Comments
 (0)