Skip to content

Commit 7804a50

Browse files
committed
feat(files): export workspace files to Google Drive
Add an Export action to the Files module that pushes selected workspace files straight to a user's Google Drive, reusing the same OAuth credentials the Google Drive block uses. The existing Download action becomes a dropdown (Download / Google Drive) in the row context menu, bulk action bar, and file-viewer menu. - Shared uploadBufferToDrive() helper; refactor the Drive tool upload route to use it (single upload path) - Contract-bound POST /api/workspaces/[id]/files/export-to-drive with per-file error reporting; tokens via refreshAccessTokenIfNeeded - ExportToDriveModal: account picker + inline connect via a dedicated 'files' OAuth return origin - Route tests cover auth, validation, token, no-files, success, partial
1 parent 192f77b commit 7804a50

16 files changed

Lines changed: 982 additions & 134 deletions

File tree

apps/sim/app/api/tools/google_drive/upload/route.ts

Lines changed: 18 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
3-
import { generateShortId } from '@sim/utils/id'
43
import { type NextRequest, NextResponse } from 'next/server'
54
import { googleDriveUploadContract } from '@/lib/api/contracts/tools/google'
65
import { parseRequest } from '@/lib/api/server'
76
import { checkInternalAuth } from '@/lib/auth/hybrid'
87
import { generateRequestId } from '@/lib/core/utils/request'
98
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { DriveUploadError, uploadBufferToDrive } from '@/lib/google-drive/upload-to-drive'
1010
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
1111
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
1212
import { assertToolFileAccess } from '@/app/api/files/authorization'
@@ -20,35 +20,6 @@ export const dynamic = 'force-dynamic'
2020

2121
const logger = createLogger('GoogleDriveUploadAPI')
2222

23-
const GOOGLE_DRIVE_API_BASE = 'https://www.googleapis.com/upload/drive/v3/files'
24-
25-
/**
26-
* Build multipart upload body for Google Drive API
27-
*/
28-
function buildMultipartBody(
29-
metadata: Record<string, any>,
30-
fileBuffer: Buffer,
31-
mimeType: string,
32-
boundary: string
33-
): string {
34-
const parts: string[] = []
35-
36-
parts.push(`--${boundary}`)
37-
parts.push('Content-Type: application/json; charset=UTF-8')
38-
parts.push('')
39-
parts.push(JSON.stringify(metadata))
40-
41-
parts.push(`--${boundary}`)
42-
parts.push(`Content-Type: ${mimeType}`)
43-
parts.push('Content-Transfer-Encoding: base64')
44-
parts.push('')
45-
parts.push(fileBuffer.toString('base64'))
46-
47-
parts.push(`--${boundary}--`)
48-
49-
return parts.join('\r\n')
50-
}
51-
5223
export const POST = withRouteHandler(async (request: NextRequest) => {
5324
const requestId = generateRequestId()
5425

@@ -159,99 +130,34 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
159130
}
160131
}
161132

162-
const metadata: {
163-
name: string
164-
mimeType: string
165-
parents?: string[]
166-
} = {
167-
name: validatedData.fileName,
168-
mimeType: requestedMimeType,
169-
}
170-
171-
if (validatedData.folderId && validatedData.folderId.trim() !== '') {
172-
metadata.parents = [validatedData.folderId.trim()]
173-
}
174-
175-
const boundary = `boundary_${Date.now()}_${generateShortId(7)}`
176-
177-
const multipartBody = buildMultipartBody(metadata, fileBuffer, uploadMimeType, boundary)
178-
179133
logger.info(`[${requestId}] Uploading to Google Drive via multipart upload`, {
180134
fileName: validatedData.fileName,
181135
size: fileBuffer.length,
182136
uploadMimeType,
183137
requestedMimeType,
184138
})
185139

186-
const uploadResponse = await fetch(
187-
`${GOOGLE_DRIVE_API_BASE}?uploadType=multipart&supportsAllDrives=true`,
188-
{
189-
method: 'POST',
190-
headers: {
191-
Authorization: `Bearer ${validatedData.accessToken}`,
192-
'Content-Type': `multipart/related; boundary=${boundary}`,
193-
'Content-Length': Buffer.byteLength(multipartBody, 'utf-8').toString(),
194-
},
195-
body: multipartBody,
196-
}
197-
)
198-
199-
if (!uploadResponse.ok) {
200-
const errorText = await uploadResponse.text()
201-
logger.error(`[${requestId}] Google Drive API error:`, {
202-
status: uploadResponse.status,
203-
statusText: uploadResponse.statusText,
204-
error: errorText,
140+
let finalFile
141+
try {
142+
finalFile = await uploadBufferToDrive({
143+
accessToken: validatedData.accessToken,
144+
name: validatedData.fileName,
145+
mimeType: requestedMimeType,
146+
uploadMimeType,
147+
buffer: fileBuffer,
148+
folderId: validatedData.folderId ?? undefined,
205149
})
206-
return NextResponse.json(
207-
{
208-
success: false,
209-
error: `Google Drive API error: ${uploadResponse.statusText}`,
210-
},
211-
{ status: uploadResponse.status }
212-
)
213-
}
214-
215-
const uploadData = await uploadResponse.json()
216-
const fileId = uploadData.id
217-
218-
logger.info(`[${requestId}] File uploaded successfully`, { fileId })
219-
220-
if (GOOGLE_WORKSPACE_MIME_TYPES.includes(requestedMimeType)) {
221-
logger.info(`[${requestId}] Updating file name to ensure it persists after conversion`)
222-
223-
const updateNameResponse = await fetch(
224-
`https://www.googleapis.com/drive/v3/files/${fileId}?supportsAllDrives=true`,
225-
{
226-
method: 'PATCH',
227-
headers: {
228-
Authorization: `Bearer ${validatedData.accessToken}`,
229-
'Content-Type': 'application/json',
230-
},
231-
body: JSON.stringify({
232-
name: validatedData.fileName,
233-
}),
234-
}
235-
)
236-
237-
if (!updateNameResponse.ok) {
238-
logger.warn(
239-
`[${requestId}] Failed to update filename after conversion, but content was uploaded`
240-
)
150+
} catch (error) {
151+
if (error instanceof DriveUploadError) {
152+
logger.error(`[${requestId}] Google Drive API error:`, {
153+
status: error.status,
154+
error: error.message,
155+
})
156+
return NextResponse.json({ success: false, error: error.message }, { status: error.status })
241157
}
158+
throw error
242159
}
243160

244-
const finalFileResponse = await fetch(
245-
`https://www.googleapis.com/drive/v3/files/${fileId}?supportsAllDrives=true&fields=id,name,mimeType,webViewLink,webContentLink,size,createdTime,modifiedTime,parents`,
246-
{
247-
headers: {
248-
Authorization: `Bearer ${validatedData.accessToken}`,
249-
},
250-
}
251-
)
252-
253-
const finalFile = await finalFileResponse.json()
254-
255161
logger.info(`[${requestId}] Upload complete`, {
256162
fileId: finalFile.id,
257163
fileName: finalFile.name,
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authMockFns } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const {
9+
mockListWorkspaceFiles,
10+
mockFetchWorkspaceFileBuffer,
11+
mockRefreshAccessTokenIfNeeded,
12+
mockUploadBufferToDrive,
13+
mockVerifyWorkspaceMembership,
14+
} = vi.hoisted(() => ({
15+
mockListWorkspaceFiles: vi.fn(),
16+
mockFetchWorkspaceFileBuffer: vi.fn(),
17+
mockRefreshAccessTokenIfNeeded: vi.fn(),
18+
mockUploadBufferToDrive: vi.fn(),
19+
mockVerifyWorkspaceMembership: vi.fn(),
20+
}))
21+
22+
vi.mock('@/lib/uploads/contexts/workspace', () => ({
23+
listWorkspaceFiles: mockListWorkspaceFiles,
24+
fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer,
25+
}))
26+
27+
vi.mock('@/app/api/auth/oauth/utils', () => ({
28+
refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded,
29+
}))
30+
31+
vi.mock('@/lib/google-drive/upload-to-drive', () => ({
32+
uploadBufferToDrive: mockUploadBufferToDrive,
33+
}))
34+
35+
vi.mock('@/app/api/workflows/utils', () => ({
36+
verifyWorkspaceMembership: mockVerifyWorkspaceMembership,
37+
}))
38+
39+
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
40+
41+
import { POST } from '@/app/api/workspaces/[id]/files/export-to-drive/route'
42+
43+
const params = (id = WS) => ({ params: Promise.resolve({ id }) })
44+
45+
const makeRequest = (body: unknown) =>
46+
new NextRequest(`http://localhost/api/workspaces/${WS}/files/export-to-drive`, {
47+
method: 'POST',
48+
headers: { 'Content-Type': 'application/json' },
49+
body: JSON.stringify(body),
50+
})
51+
52+
const fileRecord = (id: string, name: string, type = 'application/pdf') => ({
53+
id,
54+
name,
55+
type,
56+
key: `workspace/${WS}/${id}-${name}`,
57+
storageContext: 'workspace',
58+
})
59+
60+
const validBody = { fileIds: ['file-1', 'file-2'], credentialId: 'cred-1' }
61+
62+
describe('POST /api/workspaces/[id]/files/export-to-drive', () => {
63+
beforeEach(() => {
64+
vi.clearAllMocks()
65+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
66+
mockVerifyWorkspaceMembership.mockResolvedValue('read')
67+
mockRefreshAccessTokenIfNeeded.mockResolvedValue('access-token')
68+
mockListWorkspaceFiles.mockResolvedValue([
69+
fileRecord('file-1', 'a.pdf'),
70+
fileRecord('file-2', 'b.pdf'),
71+
])
72+
mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('content'))
73+
mockUploadBufferToDrive.mockImplementation(({ name }: { name: string }) =>
74+
Promise.resolve({ id: `drive-${name}`, name, webViewLink: `https://drive/${name}` })
75+
)
76+
})
77+
78+
it('returns 401 when unauthenticated', async () => {
79+
authMockFns.mockGetSession.mockResolvedValueOnce(null)
80+
const res = await POST(makeRequest(validBody), params())
81+
expect(res.status).toBe(401)
82+
expect(mockUploadBufferToDrive).not.toHaveBeenCalled()
83+
})
84+
85+
it('returns 403 when the user is not a workspace member', async () => {
86+
mockVerifyWorkspaceMembership.mockResolvedValueOnce(null)
87+
const res = await POST(makeRequest(validBody), params())
88+
expect(res.status).toBe(403)
89+
expect(mockUploadBufferToDrive).not.toHaveBeenCalled()
90+
})
91+
92+
it('returns 400 when the body is invalid (no files selected)', async () => {
93+
const res = await POST(makeRequest({ fileIds: [], credentialId: 'cred-1' }), params())
94+
expect(res.status).toBe(400)
95+
})
96+
97+
it('returns 400 when the Google Drive token cannot be resolved', async () => {
98+
mockRefreshAccessTokenIfNeeded.mockResolvedValueOnce(null)
99+
const res = await POST(makeRequest(validBody), params())
100+
const body = await res.json()
101+
expect(res.status).toBe(400)
102+
expect(body.error).toContain('not connected')
103+
expect(mockUploadBufferToDrive).not.toHaveBeenCalled()
104+
})
105+
106+
it('returns 400 when none of the requested files exist', async () => {
107+
mockListWorkspaceFiles.mockResolvedValueOnce([fileRecord('other', 'x.pdf')])
108+
const res = await POST(makeRequest(validBody), params())
109+
expect(res.status).toBe(400)
110+
expect(mockUploadBufferToDrive).not.toHaveBeenCalled()
111+
})
112+
113+
it('exports all matching files and returns success', async () => {
114+
const res = await POST(makeRequest(validBody), params())
115+
const body = await res.json()
116+
expect(res.status).toBe(200)
117+
expect(body.success).toBe(true)
118+
expect(body.exported).toHaveLength(2)
119+
expect(body.failed).toHaveLength(0)
120+
expect(body.exported[0]).toMatchObject({ fileId: 'file-1', driveFileId: 'drive-a.pdf' })
121+
expect(mockUploadBufferToDrive).toHaveBeenCalledTimes(2)
122+
})
123+
124+
it('reports partial failure without aborting the batch', async () => {
125+
mockUploadBufferToDrive
126+
.mockResolvedValueOnce({ id: 'drive-a', name: 'a.pdf', webViewLink: 'https://drive/a' })
127+
.mockRejectedValueOnce(new Error('Drive quota exceeded'))
128+
const res = await POST(makeRequest(validBody), params())
129+
const body = await res.json()
130+
expect(res.status).toBe(200)
131+
expect(body.success).toBe(false)
132+
expect(body.exported).toHaveLength(1)
133+
expect(body.failed).toHaveLength(1)
134+
expect(body.failed[0]).toMatchObject({ fileId: 'file-2', error: 'Drive quota exceeded' })
135+
})
136+
137+
it('reports requested files that no longer exist as failures', async () => {
138+
mockListWorkspaceFiles.mockResolvedValueOnce([fileRecord('file-1', 'a.pdf')])
139+
const res = await POST(
140+
makeRequest({ fileIds: ['file-1', 'file-gone'], credentialId: 'cred-1' }),
141+
params()
142+
)
143+
const body = await res.json()
144+
expect(res.status).toBe(200)
145+
expect(body.success).toBe(false)
146+
expect(body.exported).toHaveLength(1)
147+
expect(body.failed).toEqual([{ fileId: 'file-gone', error: 'File not found' }])
148+
expect(mockUploadBufferToDrive).toHaveBeenCalledTimes(1)
149+
})
150+
151+
it('only exports files that match the requested ids', async () => {
152+
await POST(makeRequest({ fileIds: ['file-1'], credentialId: 'cred-1' }), params())
153+
expect(mockUploadBufferToDrive).toHaveBeenCalledTimes(1)
154+
expect(mockUploadBufferToDrive).toHaveBeenCalledWith(
155+
expect.objectContaining({ name: 'a.pdf', accessToken: 'access-token' })
156+
)
157+
})
158+
})

0 commit comments

Comments
 (0)