Skip to content

Commit 70fa628

Browse files
authored
improvement(uploads): add multipart upload + batching + retries (#938)
* File upload retries + multipart uploads * Lint * FIle uploads * File uploads 2 * Lint * Fix file uploads * Add auth to file upload routes * Lint
1 parent b159d63 commit 70fa628

File tree

5 files changed

+785
-143
lines changed

5 files changed

+785
-143
lines changed
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import {
2+
AbortMultipartUploadCommand,
3+
CompleteMultipartUploadCommand,
4+
CreateMultipartUploadCommand,
5+
UploadPartCommand,
6+
} from '@aws-sdk/client-s3'
7+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
8+
import { type NextRequest, NextResponse } from 'next/server'
9+
import { v4 as uuidv4 } from 'uuid'
10+
import { getSession } from '@/lib/auth'
11+
import { createLogger } from '@/lib/logs/console/logger'
12+
import { getStorageProvider, isUsingCloudStorage } from '@/lib/uploads'
13+
import { S3_KB_CONFIG } from '@/lib/uploads/setup'
14+
15+
const logger = createLogger('MultipartUploadAPI')
16+
17+
interface InitiateMultipartRequest {
18+
fileName: string
19+
contentType: string
20+
fileSize: number
21+
}
22+
23+
interface GetPartUrlsRequest {
24+
uploadId: string
25+
key: string
26+
partNumbers: number[]
27+
}
28+
29+
interface CompleteMultipartRequest {
30+
uploadId: string
31+
key: string
32+
parts: Array<{
33+
ETag: string
34+
PartNumber: number
35+
}>
36+
}
37+
38+
export async function POST(request: NextRequest) {
39+
try {
40+
const session = await getSession()
41+
if (!session?.user?.id) {
42+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
43+
}
44+
45+
const action = request.nextUrl.searchParams.get('action')
46+
47+
if (!isUsingCloudStorage() || getStorageProvider() !== 's3') {
48+
return NextResponse.json(
49+
{ error: 'Multipart upload is only available with S3 storage' },
50+
{ status: 400 }
51+
)
52+
}
53+
54+
const { getS3Client } = await import('@/lib/uploads/s3/s3-client')
55+
const s3Client = getS3Client()
56+
57+
switch (action) {
58+
case 'initiate': {
59+
const data: InitiateMultipartRequest = await request.json()
60+
const { fileName, contentType } = data
61+
62+
const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
63+
const uniqueKey = `kb/${uuidv4()}-${safeFileName}`
64+
65+
const command = new CreateMultipartUploadCommand({
66+
Bucket: S3_KB_CONFIG.bucket,
67+
Key: uniqueKey,
68+
ContentType: contentType,
69+
Metadata: {
70+
originalName: fileName,
71+
uploadedAt: new Date().toISOString(),
72+
purpose: 'knowledge-base',
73+
},
74+
})
75+
76+
const response = await s3Client.send(command)
77+
78+
logger.info(`Initiated multipart upload for ${fileName}: ${response.UploadId}`)
79+
80+
return NextResponse.json({
81+
uploadId: response.UploadId,
82+
key: uniqueKey,
83+
})
84+
}
85+
86+
case 'get-part-urls': {
87+
const data: GetPartUrlsRequest = await request.json()
88+
const { uploadId, key, partNumbers } = data
89+
90+
const presignedUrls = await Promise.all(
91+
partNumbers.map(async (partNumber) => {
92+
const command = new UploadPartCommand({
93+
Bucket: S3_KB_CONFIG.bucket,
94+
Key: key,
95+
PartNumber: partNumber,
96+
UploadId: uploadId,
97+
})
98+
99+
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 })
100+
return { partNumber, url }
101+
})
102+
)
103+
104+
return NextResponse.json({ presignedUrls })
105+
}
106+
107+
case 'complete': {
108+
const data: CompleteMultipartRequest = await request.json()
109+
const { uploadId, key, parts } = data
110+
111+
const command = new CompleteMultipartUploadCommand({
112+
Bucket: S3_KB_CONFIG.bucket,
113+
Key: key,
114+
UploadId: uploadId,
115+
MultipartUpload: {
116+
Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber),
117+
},
118+
})
119+
120+
const response = await s3Client.send(command)
121+
122+
logger.info(`Completed multipart upload for key ${key}`)
123+
124+
const finalPath = `/api/files/serve/s3/${encodeURIComponent(key)}`
125+
126+
return NextResponse.json({
127+
success: true,
128+
location: response.Location,
129+
path: finalPath,
130+
key,
131+
})
132+
}
133+
134+
case 'abort': {
135+
const data = await request.json()
136+
const { uploadId, key } = data
137+
138+
const command = new AbortMultipartUploadCommand({
139+
Bucket: S3_KB_CONFIG.bucket,
140+
Key: key,
141+
UploadId: uploadId,
142+
})
143+
144+
await s3Client.send(command)
145+
146+
logger.info(`Aborted multipart upload for key ${key}`)
147+
148+
return NextResponse.json({ success: true })
149+
}
150+
151+
default:
152+
return NextResponse.json(
153+
{ error: 'Invalid action. Use: initiate, get-part-urls, complete, or abort' },
154+
{ status: 400 }
155+
)
156+
}
157+
} catch (error) {
158+
logger.error('Multipart upload error:', error)
159+
return NextResponse.json(
160+
{ error: error instanceof Error ? error.message : 'Multipart upload failed' },
161+
{ status: 500 }
162+
)
163+
}
164+
}

apps/sim/app/api/files/presigned/route.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { PutObjectCommand } from '@aws-sdk/client-s3'
22
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { v4 as uuidv4 } from 'uuid'
5+
import { getSession } from '@/lib/auth'
56
import { createLogger } from '@/lib/logs/console/logger'
67
import { getStorageProvider, isUsingCloudStorage } from '@/lib/uploads'
78
// Dynamic imports for storage clients to avoid client-side bundling
@@ -54,14 +55,19 @@ class ValidationError extends PresignedUrlError {
5455

5556
export async function POST(request: NextRequest) {
5657
try {
58+
const session = await getSession()
59+
if (!session?.user?.id) {
60+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
61+
}
62+
5763
let data: PresignedUrlRequest
5864
try {
5965
data = await request.json()
6066
} catch {
6167
throw new ValidationError('Invalid JSON in request body')
6268
}
6369

64-
const { fileName, contentType, fileSize, userId, chatId } = data
70+
const { fileName, contentType, fileSize } = data
6571

6672
if (!fileName?.trim()) {
6773
throw new ValidationError('fileName is required and cannot be empty')
@@ -90,10 +96,13 @@ export async function POST(request: NextRequest) {
9096
? 'copilot'
9197
: 'general'
9298

93-
// Validate copilot-specific requirements
99+
// Evaluate user id from session for copilot uploads
100+
const sessionUserId = session.user.id
101+
102+
// Validate copilot-specific requirements (use session user)
94103
if (uploadType === 'copilot') {
95-
if (!userId?.trim()) {
96-
throw new ValidationError('userId is required for copilot uploads')
104+
if (!sessionUserId?.trim()) {
105+
throw new ValidationError('Authenticated user session is required for copilot uploads')
97106
}
98107
}
99108

@@ -108,9 +117,21 @@ export async function POST(request: NextRequest) {
108117

109118
switch (storageProvider) {
110119
case 's3':
111-
return await handleS3PresignedUrl(fileName, contentType, fileSize, uploadType, userId)
120+
return await handleS3PresignedUrl(
121+
fileName,
122+
contentType,
123+
fileSize,
124+
uploadType,
125+
sessionUserId
126+
)
112127
case 'blob':
113-
return await handleBlobPresignedUrl(fileName, contentType, fileSize, uploadType, userId)
128+
return await handleBlobPresignedUrl(
129+
fileName,
130+
contentType,
131+
fileSize,
132+
uploadType,
133+
sessionUserId
134+
)
114135
default:
115136
throw new StorageConfigError(`Unknown storage provider: ${storageProvider}`)
116137
}

apps/sim/app/api/files/upload/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
22
import { createLogger } from '@/lib/logs/console/logger'
33
import { getPresignedUrl, isUsingCloudStorage, uploadFile } from '@/lib/uploads'
44
import '@/lib/uploads/setup.server'
5+
import { getSession } from '@/lib/auth'
56
import {
67
createErrorResponse,
78
createOptionsResponse,
@@ -14,6 +15,11 @@ const logger = createLogger('FilesUploadAPI')
1415

1516
export async function POST(request: NextRequest) {
1617
try {
18+
const session = await getSession()
19+
if (!session?.user?.id) {
20+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
21+
}
22+
1723
const formData = await request.formData()
1824

1925
// Check if multiple files are being uploaded or a single file

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/upload-modal/upload-modal.tsx

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
'use client'
22

33
import { useRef, useState } from 'react'
4-
import { X } from 'lucide-react'
4+
import { Check, Loader2, X } from 'lucide-react'
55
import { Button } from '@/components/ui/button'
66
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
77
import { Label } from '@/components/ui/label'
8+
import { Progress } from '@/components/ui/progress'
89
import { createLogger } from '@/lib/logs/console/logger'
910
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
1011

@@ -151,9 +152,15 @@ export function UploadModal({
151152
}
152153
}
153154

155+
// Calculate progress percentage
156+
const progressPercentage =
157+
uploadProgress.totalFiles > 0
158+
? Math.round((uploadProgress.filesCompleted / uploadProgress.totalFiles) * 100)
159+
: 0
160+
154161
return (
155162
<Dialog open={open} onOpenChange={handleClose}>
156-
<DialogContent className='flex max-h-[90vh] max-w-2xl flex-col overflow-hidden'>
163+
<DialogContent className='flex max-h-[95vh] max-w-2xl flex-col overflow-hidden'>
157164
<DialogHeader>
158165
<DialogTitle>Upload Documents</DialogTitle>
159166
</DialogHeader>
@@ -218,30 +225,55 @@ export function UploadModal({
218225
</p>
219226
</div>
220227

221-
<div className='max-h-40 space-y-2 overflow-auto'>
222-
{files.map((file, index) => (
223-
<div
224-
key={index}
225-
className='flex items-center justify-between rounded-md border p-3'
226-
>
227-
<div className='min-w-0 flex-1'>
228-
<p className='truncate font-medium text-sm'>{file.name}</p>
229-
<p className='text-muted-foreground text-xs'>
230-
{(file.size / 1024 / 1024).toFixed(2)} MB
231-
</p>
228+
<div className='max-h-60 space-y-1.5 overflow-auto'>
229+
{files.map((file, index) => {
230+
const fileStatus = uploadProgress.fileStatuses?.[index]
231+
const isCurrentlyUploading = fileStatus?.status === 'uploading'
232+
const isCompleted = fileStatus?.status === 'completed'
233+
const isFailed = fileStatus?.status === 'failed'
234+
235+
return (
236+
<div key={index} className='space-y-1.5 rounded-md border p-2'>
237+
<div className='flex items-center justify-between'>
238+
<div className='min-w-0 flex-1'>
239+
<div className='flex items-center gap-2'>
240+
{isCurrentlyUploading && (
241+
<Loader2 className='h-4 w-4 animate-spin text-blue-500' />
242+
)}
243+
{isCompleted && <Check className='h-4 w-4 text-green-500' />}
244+
{isFailed && <X className='h-4 w-4 text-red-500' />}
245+
{!isCurrentlyUploading && !isCompleted && !isFailed && (
246+
<div className='h-4 w-4' />
247+
)}
248+
<p className='truncate text-sm'>
249+
<span className='font-medium'>{file.name}</span>
250+
<span className='text-muted-foreground'>
251+
{' '}
252+
{(file.size / 1024 / 1024).toFixed(2)} MB
253+
</span>
254+
</p>
255+
</div>
256+
</div>
257+
<Button
258+
type='button'
259+
variant='ghost'
260+
size='sm'
261+
onClick={() => removeFile(index)}
262+
disabled={isUploading}
263+
className='h-8 w-8 p-0'
264+
>
265+
<X className='h-4 w-4' />
266+
</Button>
267+
</div>
268+
{isCurrentlyUploading && (
269+
<Progress value={fileStatus?.progress || 0} className='h-1' />
270+
)}
271+
{isFailed && fileStatus?.error && (
272+
<p className='text-red-500 text-xs'>{fileStatus.error}</p>
273+
)}
232274
</div>
233-
<Button
234-
type='button'
235-
variant='ghost'
236-
size='sm'
237-
onClick={() => removeFile(index)}
238-
disabled={isUploading}
239-
className='h-8 w-8 p-0'
240-
>
241-
<X className='h-4 w-4' />
242-
</Button>
243-
</div>
244-
))}
275+
)
276+
})}
245277
</div>
246278
</div>
247279
)}

0 commit comments

Comments
 (0)