Skip to content

Commit dba6e2f

Browse files
committed
improvement(uploads): migrate profile-picture and workspace-logo uploads to presigned PUT
1 parent ebf97a2 commit dba6e2f

5 files changed

Lines changed: 145 additions & 48 deletions

File tree

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const VALID_UPLOAD_TYPES = [
2222
'copilot',
2323
'profile-pictures',
2424
'mothership',
25+
'workspace-logos',
2526
] as const
2627

2728
class PresignedUrlError extends Error {
@@ -154,6 +155,37 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
154155
expirationSeconds: 3600,
155156
metadata: { workspaceId },
156157
})
158+
} else if (uploadType === 'workspace-logos') {
159+
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
160+
if (!workspaceId?.trim()) {
161+
throw new ValidationError(
162+
'workspaceId query parameter is required for workspace-logos uploads'
163+
)
164+
}
165+
166+
const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
167+
if (permission === null) {
168+
return NextResponse.json(
169+
{ error: 'Insufficient permissions for workspace' },
170+
{ status: 403 }
171+
)
172+
}
173+
174+
if (!isImageFileType(contentType)) {
175+
throw new ValidationError(
176+
'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for workspace logo uploads'
177+
)
178+
}
179+
180+
presignedUrlResponse = await generatePresignedUploadUrl({
181+
fileName,
182+
contentType,
183+
fileSize,
184+
context: 'workspace-logos',
185+
userId: sessionUserId,
186+
expirationSeconds: 3600,
187+
metadata: { workspaceId },
188+
})
157189
} else {
158190
if (uploadType === 'profile-pictures') {
159191
if (!sessionUserId?.trim()) {

apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { createLogger } from '@sim/logger'
3+
import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
34
import type { StorageContext } from '@/lib/uploads/shared/types'
45

56
const logger = createLogger('ProfilePictureUpload')
@@ -10,10 +11,49 @@ interface UseProfilePictureUploadProps {
1011
onUpload?: (url: string | null) => void
1112
onError?: (error: string) => void
1213
currentImage?: string | null
13-
context?: StorageContext
14+
context?: 'profile-pictures' | 'workspace-logos'
1415
workspaceId?: string
1516
}
1617

18+
/**
19+
* Server-proxied fallback used only when cloud storage isn't configured (local dev).
20+
* Production always takes the presigned PUT path.
21+
*/
22+
async function uploadViaApiFallback(
23+
file: File,
24+
context: StorageContext,
25+
workspaceId?: string
26+
): Promise<string> {
27+
const formData = new FormData()
28+
formData.append('file', file)
29+
formData.append('context', context)
30+
if (workspaceId) {
31+
formData.append('workspaceId', workspaceId)
32+
}
33+
34+
// boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson
35+
const response = await fetch('/api/files/upload', { method: 'POST', body: formData })
36+
if (!response.ok) {
37+
const errorData = (await response.json().catch(() => ({}))) as {
38+
message?: string
39+
error?: string
40+
}
41+
throw new Error(
42+
errorData.message || errorData.error || `Failed to upload file: ${response.status}`
43+
)
44+
}
45+
const data = (await response.json()) as {
46+
fileInfo?: { path?: string }
47+
path?: string
48+
url?: string
49+
}
50+
const publicUrl = data.fileInfo?.path ?? data.path ?? data.url
51+
if (!publicUrl) {
52+
throw new Error('Invalid upload response: missing path')
53+
}
54+
return publicUrl
55+
}
56+
1757
/**
1858
* Hook for handling profile picture upload functionality.
1959
* Manages file validation, preview generation, and server upload.
@@ -64,33 +104,27 @@ export function useProfilePictureUpload({
64104

65105
const uploadFileToServer = useCallback(
66106
async (file: File): Promise<string> => {
67-
try {
68-
const formData = new FormData()
69-
formData.append('file', file)
70-
formData.append('context', context)
71-
if (workspaceId) {
72-
formData.append('workspaceId', workspaceId)
73-
}
107+
const presignedEndpoint =
108+
context === 'workspace-logos' && workspaceId
109+
? `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(workspaceId)}`
110+
: `/api/files/presigned?type=${context}`
74111

75-
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
76-
const response = await fetch('/api/files/upload', {
77-
method: 'POST',
78-
body: formData,
112+
try {
113+
const result = await runUploadStrategy({
114+
file,
115+
workspaceId: workspaceId ?? '',
116+
context,
117+
presignedEndpoint,
79118
})
80-
81-
if (!response.ok) {
82-
const errorData = await response.json().catch(() => ({ message: response.statusText }))
83-
throw new Error(
84-
errorData.message || errorData.error || `Failed to upload file: ${response.status}`
85-
)
86-
}
87-
88-
const data = await response.json()
89-
const publicUrl = data.fileInfo?.path || data.path || data.url
90-
logger.info(`Profile picture uploaded successfully via server upload: ${publicUrl}`)
91-
return publicUrl
119+
logger.info(`${context} uploaded successfully: ${result.path}`)
120+
return result.path
92121
} catch (error) {
93-
throw new Error(error instanceof Error ? error.message : 'Failed to upload profile picture')
122+
if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
123+
const publicUrl = await uploadViaApiFallback(file, context, workspaceId)
124+
logger.info(`${context} uploaded successfully via API fallback: ${publicUrl}`)
125+
return publicUrl
126+
}
127+
throw error
94128
}
95129
},
96130
[context, workspaceId]

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,40 @@
11
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { createLogger } from '@sim/logger'
3+
import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
34

45
const logger = createLogger('WorkspaceLogoUpload')
56
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
67
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml', 'image/webp']
78

9+
async function uploadViaApiFallback(file: File, workspaceId: string): Promise<string> {
10+
const formData = new FormData()
11+
formData.append('file', file)
12+
formData.append('context', 'workspace-logos')
13+
formData.append('workspaceId', workspaceId)
14+
15+
// boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson
16+
const response = await fetch('/api/files/upload', { method: 'POST', body: formData })
17+
if (!response.ok) {
18+
const errorData = (await response.json().catch(() => ({}))) as {
19+
message?: string
20+
error?: string
21+
}
22+
throw new Error(
23+
errorData.message || errorData.error || `Failed to upload file: ${response.status}`
24+
)
25+
}
26+
const data = (await response.json()) as {
27+
fileInfo?: { path?: string }
28+
path?: string
29+
url?: string
30+
}
31+
const publicUrl = data.fileInfo?.path ?? data.path ?? data.url
32+
if (!publicUrl) {
33+
throw new Error('Invalid upload response: missing path')
34+
}
35+
return publicUrl
36+
}
37+
838
interface UseWorkspaceLogoUploadProps {
939
workspaceId?: string
1040
currentLogoUrl?: string | null
@@ -60,30 +90,30 @@ export function useWorkspaceLogoUpload({
6090
}, [])
6191

6292
const uploadFileToServer = useCallback(async (file: File): Promise<string> => {
63-
const formData = new FormData()
64-
formData.append('file', file)
65-
formData.append('context', 'workspace-logos')
66-
if (workspaceIdRef.current) {
67-
formData.append('workspaceId', workspaceIdRef.current)
93+
const targetWorkspaceId = workspaceIdRef.current
94+
if (!targetWorkspaceId) {
95+
throw new Error('workspaceId is required for workspace logo upload')
6896
}
6997

70-
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
71-
const response = await fetch('/api/files/upload', {
72-
method: 'POST',
73-
body: formData,
74-
})
75-
76-
if (!response.ok) {
77-
const errorData = await response.json().catch(() => ({ message: response.statusText }))
78-
throw new Error(
79-
errorData.message || errorData.error || `Failed to upload file: ${response.status}`
80-
)
98+
const presignedEndpoint = `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(targetWorkspaceId)}`
99+
100+
try {
101+
const result = await runUploadStrategy({
102+
file,
103+
workspaceId: targetWorkspaceId,
104+
context: 'workspace-logos',
105+
presignedEndpoint,
106+
})
107+
logger.info(`Workspace logo uploaded successfully: ${result.path}`)
108+
return result.path
109+
} catch (error) {
110+
if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
111+
const publicUrl = await uploadViaApiFallback(file, targetWorkspaceId)
112+
logger.info(`Workspace logo uploaded via API fallback: ${publicUrl}`)
113+
return publicUrl
114+
}
115+
throw error
81116
}
82-
83-
const data = await response.json()
84-
const publicUrl = data.fileInfo?.path || data.path || data.url
85-
logger.info(`Workspace logo uploaded successfully: ${publicUrl}`)
86-
return publicUrl
87117
}, [])
88118

89119
const processFile = useCallback(

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ export const validUploadTypes = [
322322
'copilot',
323323
'profile-pictures',
324324
'mothership',
325+
'workspace-logos',
325326
] as const
326327

327328
export const uploadTypeSchema = z.enum(validUploadTypes)

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' | 'mothership'
306+
context: 'workspace' | 'knowledge-base' | 'mothership' | 'profile-pictures' | 'workspace-logos'
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' | 'mothership'
520+
context: 'workspace' | 'knowledge-base' | 'mothership' | 'profile-pictures' | 'workspace-logos'
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)