Skip to content

Commit c4192f5

Browse files
committed
fix(uploads): switch mothership uploads to presigned PUT pattern
1 parent 408669d commit c4192f5

4 files changed

Lines changed: 117 additions & 37 deletions

File tree

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,23 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
77
import { CopilotFiles } from '@/lib/uploads'
88
import type { StorageContext } from '@/lib/uploads/config'
99
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
10+
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
1011
import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service'
12+
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
1113
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
1214
import { validateFileType } from '@/lib/uploads/utils/validation'
15+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1316
import { createErrorResponse } from '@/app/api/files/utils'
1417

1518
const logger = createLogger('PresignedUploadAPI')
1619

17-
const VALID_UPLOAD_TYPES = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] as const
20+
const VALID_UPLOAD_TYPES = [
21+
'knowledge-base',
22+
'chat',
23+
'copilot',
24+
'profile-pictures',
25+
'mothership',
26+
] as const
1827

1928
class PresignedUrlError extends Error {
2029
constructor(
@@ -116,6 +125,43 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
116125
error instanceof Error ? error.message : 'Copilot validation failed'
117126
)
118127
}
128+
} else if (uploadType === 'mothership') {
129+
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
130+
if (!workspaceId?.trim()) {
131+
throw new ValidationError('workspaceId query parameter is required for mothership uploads')
132+
}
133+
134+
const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
135+
if (permission === null) {
136+
return NextResponse.json(
137+
{ error: 'Insufficient permissions for workspace' },
138+
{ status: 403 }
139+
)
140+
}
141+
142+
const fileValidationError = validateFileType(fileName, contentType)
143+
if (fileValidationError) {
144+
throw new ValidationError(fileValidationError.message)
145+
}
146+
147+
if (fileSize > MAX_WORKSPACE_FILE_SIZE) {
148+
return NextResponse.json(
149+
{ error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` },
150+
{ status: 413 }
151+
)
152+
}
153+
154+
const customKey = generateWorkspaceFileKey(workspaceId, fileName)
155+
presignedUrlResponse = await generatePresignedUploadUrl({
156+
fileName,
157+
contentType,
158+
fileSize,
159+
context: 'mothership',
160+
userId: sessionUserId,
161+
customKey,
162+
expirationSeconds: 3600,
163+
metadata: { workspaceId },
164+
})
119165
} else {
120166
if (uploadType === 'profile-pictures') {
121167
if (!sessionUserId?.trim()) {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
55
import { toError } from '@sim/utils/errors'
66
import { generateId } from '@sim/utils/id'
77
import { toast } from '@/components/emcn'
8+
import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
89
import { resolveFileType } from '@/lib/uploads/utils/file-utils'
910

1011
const logger = createLogger('useFileAttachments')
@@ -51,6 +52,44 @@ interface UseFileAttachmentsProps {
5152
isLoading?: boolean
5253
}
5354

55+
/**
56+
* Server-proxied fallback used only when cloud storage isn't configured (local dev).
57+
* Production always takes the presigned PUT path.
58+
*/
59+
async function uploadViaApiFallback(
60+
file: File,
61+
workspaceId: string
62+
): Promise<{ path: string; key: string }> {
63+
const formData = new FormData()
64+
formData.append('file', file)
65+
formData.append('context', 'mothership')
66+
formData.append('workspaceId', workspaceId)
67+
68+
// boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson
69+
const response = await fetch('/api/files/upload', { method: 'POST', body: formData })
70+
if (!response.ok) {
71+
const errorData = (await response.json().catch(() => ({}))) as {
72+
message?: string
73+
error?: string
74+
}
75+
throw new Error(
76+
errorData.message || errorData.error || `Failed to upload file: ${response.status}`
77+
)
78+
}
79+
const data = (await response.json()) as {
80+
fileInfo?: { path?: string; key?: string }
81+
path?: string
82+
key?: string
83+
url?: string
84+
}
85+
const path = data.fileInfo?.path ?? data.path ?? data.url
86+
const key = data.fileInfo?.key ?? data.key
87+
if (!path || !key) {
88+
throw new Error('Invalid upload response: missing path or key')
89+
}
90+
return { path, key }
91+
}
92+
5493
/**
5594
* Custom hook to manage file attachments including upload, drag/drop, and preview
5695
* Handles S3 presigned URL uploads and preview URL generation
@@ -115,6 +154,10 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
115154
logger.error('User ID not available for file upload')
116155
return
117156
}
157+
if (!workspaceId) {
158+
logger.error('workspaceId required for mothership uploads')
159+
return
160+
}
118161

119162
const files = Array.from(fileList)
120163
if (files.length === 0) return
@@ -134,49 +177,34 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
134177

135178
setAttachedFiles((prev) => [...prev, ...placeholders])
136179

180+
const presignedEndpoint = `/api/files/presigned?type=mothership&workspaceId=${encodeURIComponent(workspaceId)}`
181+
137182
await Promise.all(
138183
files.map(async (file, i) => {
139184
const placeholder = placeholders[i]
140185
try {
141-
const formData = new FormData()
142-
formData.append('file', file)
143-
formData.append('context', 'mothership')
144-
if (workspaceId) {
145-
formData.append('workspaceId', workspaceId)
186+
let result: { path: string; key: string }
187+
try {
188+
result = await runUploadStrategy({
189+
file,
190+
workspaceId,
191+
context: 'mothership',
192+
presignedEndpoint,
193+
})
194+
} catch (error) {
195+
if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
196+
result = await uploadViaApiFallback(file, workspaceId)
197+
} else {
198+
throw error
199+
}
146200
}
147201

148-
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
149-
const uploadResponse = await fetch('/api/files/upload', {
150-
method: 'POST',
151-
body: formData,
152-
})
153-
154-
if (!uploadResponse.ok) {
155-
const errorData = await uploadResponse.json().catch(() => ({
156-
message: `Upload failed: ${uploadResponse.status}`,
157-
}))
158-
throw new Error(
159-
errorData.message ||
160-
errorData.error ||
161-
`Failed to upload file: ${uploadResponse.status}`
162-
)
163-
}
164-
165-
const uploadData = await uploadResponse.json()
166-
167-
logger.info(
168-
`File uploaded successfully: ${uploadData.fileInfo?.path || uploadData.path}`
169-
)
202+
logger.info(`File uploaded successfully: ${result.path}`)
170203

171204
setAttachedFiles((prev) =>
172205
prev.map((f) =>
173206
f.id === placeholder.id
174-
? {
175-
...f,
176-
path: uploadData.fileInfo?.path || uploadData.path || uploadData.url,
177-
key: uploadData.fileInfo?.key || uploadData.key,
178-
uploading: false,
179-
}
207+
? { ...f, path: result.path, key: result.key, uploading: false }
180208
: f
181209
)
182210
)

apps/sim/lib/api/contracts/storage-transfer.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,13 @@ export const fileDeleteBodySchema = z
316316
.passthrough()
317317

318318
const MAX_FILE_SIZE = 100 * 1024 * 1024
319-
export const validUploadTypes = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] as const
319+
export const validUploadTypes = [
320+
'knowledge-base',
321+
'chat',
322+
'copilot',
323+
'profile-pictures',
324+
'mothership',
325+
] as const
320326

321327
export const uploadTypeSchema = z.enum(validUploadTypes)
322328

apps/sim/lib/uploads/client/direct-upload.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ const uploadViaPresignedPut = (opts: UploadViaPutOptions): Promise<void> => {
303303
interface MultipartUploadOptions {
304304
file: File
305305
workspaceId: string
306-
context: 'workspace' | 'knowledge-base'
306+
context: 'workspace' | 'knowledge-base' | 'mothership'
307307
signal?: AbortSignal
308308
onProgress?: (event: UploadProgressEvent) => void
309309
}
@@ -517,7 +517,7 @@ const uploadViaMultipart = async (
517517
export interface RunUploadStrategyOptions {
518518
file: File
519519
workspaceId: string
520-
context: 'workspace' | 'knowledge-base'
520+
context: 'workspace' | 'knowledge-base' | 'mothership'
521521
/** Endpoint to mint a presigned PUT URL. Required unless `presignedOverride` is provided. */
522522
presignedEndpoint?: string
523523
/** Pre-fetched presigned data (e.g. from a batch endpoint). Skips per-file fetch. */

0 commit comments

Comments
 (0)