Skip to content

Commit f4cbf5d

Browse files
authored
feat(file-upload): generalized storage to support azure blob, enhanced error logging in kb, added xlsx parser (#506)
* added blob storage option for azure, refactored storage client to be provider agnostic, tested kb & file upload and s3 is undisrupted, still have to test blob * updated CORS policy for blob, added azure blob-specific headers * remove extraneous comments * add file size limit and timeout * added some extra error handling in kb add documents * grouped envvars * ack PR comments * added sheetjs and xlsx parser
1 parent 13c4fc9 commit f4cbf5d

File tree

39 files changed

+2800
-1315
lines changed

39 files changed

+2800
-1315
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ sim-standalone.tar.gz
2929
# misc
3030
.DS_Store
3131
*.pem
32-
uploads/
3332

3433
# env files
3534
.env

apps/sim/app/api/files/delete/route.test.ts

Lines changed: 36 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,15 @@
1-
/**
2-
* Tests for file delete API route
3-
*
4-
* @vitest-environment node
5-
*/
61
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
72
import { createMockRequest } from '@/app/api/__test-utils__/utils'
83

94
describe('File Delete API Route', () => {
10-
// Mock file system modules
115
const mockUnlink = vi.fn().mockResolvedValue(undefined)
126
const mockExistsSync = vi.fn().mockReturnValue(true)
13-
const mockDeleteFromS3 = vi.fn().mockResolvedValue(undefined)
14-
const mockEnsureUploadsDirectory = vi.fn().mockResolvedValue(true)
7+
const mockDeleteFile = vi.fn().mockResolvedValue(undefined)
8+
const mockIsUsingCloudStorage = vi.fn().mockReturnValue(false)
159

1610
beforeEach(() => {
1711
vi.resetModules()
1812

19-
// Mock filesystem operations
2013
vi.doMock('fs', () => ({
2114
existsSync: mockExistsSync,
2215
}))
@@ -25,12 +18,11 @@ describe('File Delete API Route', () => {
2518
unlink: mockUnlink,
2619
}))
2720

28-
// Mock the S3 client
29-
vi.doMock('@/lib/uploads/s3-client', () => ({
30-
deleteFromS3: mockDeleteFromS3,
21+
vi.doMock('@/lib/uploads', () => ({
22+
deleteFile: mockDeleteFile,
23+
isUsingCloudStorage: mockIsUsingCloudStorage,
3124
}))
3225

33-
// Mock the logger
3426
vi.doMock('@/lib/logs/console-logger', () => ({
3527
createLogger: vi.fn().mockReturnValue({
3628
info: vi.fn(),
@@ -40,18 +32,12 @@ describe('File Delete API Route', () => {
4032
}),
4133
}))
4234

43-
// Configure upload directory and S3 mode with all required exports
4435
vi.doMock('@/lib/uploads/setup', () => ({
4536
UPLOAD_DIR: '/test/uploads',
4637
USE_S3_STORAGE: false,
47-
ensureUploadsDirectory: mockEnsureUploadsDirectory,
48-
S3_CONFIG: {
49-
bucket: 'test-bucket',
50-
region: 'test-region',
51-
},
38+
USE_BLOB_STORAGE: false,
5239
}))
5340

54-
// Skip setup.server.ts side effects
5541
vi.doMock('@/lib/uploads/setup.server', () => ({}))
5642
})
5743

@@ -60,111 +46,114 @@ describe('File Delete API Route', () => {
6046
})
6147

6248
it('should handle local file deletion successfully', async () => {
63-
// Configure upload directory and S3 mode for this test
6449
vi.doMock('@/lib/uploads/setup', () => ({
6550
UPLOAD_DIR: '/test/uploads',
6651
USE_S3_STORAGE: false,
6752
}))
6853

69-
// Create request with file path
7054
const req = createMockRequest('POST', {
7155
filePath: '/api/files/serve/test-file.txt',
7256
})
7357

74-
// Import the handler after mocks are set up
7558
const { POST } = await import('./route')
7659

77-
// Call the handler
7860
const response = await POST(req)
7961
const data = await response.json()
8062

81-
// Verify response
8263
expect(response.status).toBe(200)
8364
expect(data).toHaveProperty('success', true)
8465
expect(data).toHaveProperty('message', 'File deleted successfully')
8566

86-
// Verify unlink was called with correct path
8767
expect(mockUnlink).toHaveBeenCalledWith('/test/uploads/test-file.txt')
8868
})
8969

9070
it('should handle file not found gracefully', async () => {
91-
// Mock file not existing
9271
mockExistsSync.mockReturnValueOnce(false)
9372

94-
// Create request with file path
9573
const req = createMockRequest('POST', {
9674
filePath: '/api/files/serve/nonexistent.txt',
9775
})
9876

99-
// Import the handler after mocks are set up
10077
const { POST } = await import('./route')
10178

102-
// Call the handler
10379
const response = await POST(req)
10480
const data = await response.json()
10581

106-
// Verify response
10782
expect(response.status).toBe(200)
10883
expect(data).toHaveProperty('success', true)
10984
expect(data).toHaveProperty('message', "File not found, but that's okay")
11085

111-
// Verify unlink was not called
11286
expect(mockUnlink).not.toHaveBeenCalled()
11387
})
11488

11589
it('should handle S3 file deletion successfully', async () => {
116-
// Configure upload directory and S3 mode for this test
11790
vi.doMock('@/lib/uploads/setup', () => ({
11891
UPLOAD_DIR: '/test/uploads',
11992
USE_S3_STORAGE: true,
93+
USE_BLOB_STORAGE: false,
12094
}))
12195

122-
// Create request with S3 file path
96+
mockIsUsingCloudStorage.mockReturnValue(true)
97+
12398
const req = createMockRequest('POST', {
12499
filePath: '/api/files/serve/s3/1234567890-test-file.txt',
125100
})
126101

127-
// Import the handler after mocks are set up
128102
const { POST } = await import('./route')
129103

130-
// Call the handler
131104
const response = await POST(req)
132105
const data = await response.json()
133106

134-
// Verify response
135107
expect(response.status).toBe(200)
136108
expect(data).toHaveProperty('success', true)
137-
expect(data).toHaveProperty('message', 'File deleted successfully from S3')
109+
expect(data).toHaveProperty('message', 'File deleted successfully from cloud storage')
110+
111+
expect(mockDeleteFile).toHaveBeenCalledWith('1234567890-test-file.txt')
112+
})
113+
114+
it('should handle Azure Blob file deletion successfully', async () => {
115+
vi.doMock('@/lib/uploads/setup', () => ({
116+
UPLOAD_DIR: '/test/uploads',
117+
USE_S3_STORAGE: false,
118+
USE_BLOB_STORAGE: true,
119+
}))
120+
121+
mockIsUsingCloudStorage.mockReturnValue(true)
122+
123+
const req = createMockRequest('POST', {
124+
filePath: '/api/files/serve/blob/1234567890-test-document.pdf',
125+
})
126+
127+
const { POST } = await import('./route')
128+
129+
const response = await POST(req)
130+
const data = await response.json()
131+
132+
expect(response.status).toBe(200)
133+
expect(data).toHaveProperty('success', true)
134+
expect(data).toHaveProperty('message', 'File deleted successfully from cloud storage')
138135

139-
// Verify deleteFromS3 was called with correct key
140-
expect(mockDeleteFromS3).toHaveBeenCalledWith('1234567890-test-file.txt')
136+
expect(mockDeleteFile).toHaveBeenCalledWith('1234567890-test-document.pdf')
141137
})
142138

143139
it('should handle missing file path', async () => {
144-
// Create request with no file path
145140
const req = createMockRequest('POST', {})
146141

147-
// Import the handler after mocks are set up
148142
const { POST } = await import('./route')
149143

150-
// Call the handler
151144
const response = await POST(req)
152145
const data = await response.json()
153146

154-
// Verify error response
155147
expect(response.status).toBe(400)
156148
expect(data).toHaveProperty('error', 'InvalidRequestError')
157149
expect(data).toHaveProperty('message', 'No file path provided')
158150
})
159151

160152
it('should handle CORS preflight requests', async () => {
161-
// Import the handler after mocks are set up
162153
const { OPTIONS } = await import('./route')
163154

164-
// Call the handler
165155
const response = await OPTIONS()
166156

167-
// Verify response
168157
expect(response.status).toBe(204)
169158
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, DELETE, OPTIONS')
170159
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type')

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

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@ import { unlink } from 'fs/promises'
33
import { join } from 'path'
44
import type { NextRequest } from 'next/server'
55
import { createLogger } from '@/lib/logs/console-logger'
6-
import { deleteFromS3 } from '@/lib/uploads/s3-client'
7-
import { UPLOAD_DIR, USE_S3_STORAGE } from '@/lib/uploads/setup'
6+
import { deleteFile, isUsingCloudStorage } from '@/lib/uploads'
7+
import { UPLOAD_DIR } from '@/lib/uploads/setup'
88
import '@/lib/uploads/setup.server'
99

1010
import {
1111
createErrorResponse,
1212
createOptionsResponse,
1313
createSuccessResponse,
14+
extractBlobKey,
1415
extractFilename,
1516
extractS3Key,
1617
InvalidRequestError,
18+
isBlobPath,
19+
isCloudPath,
1720
isS3Path,
1821
} from '../utils'
1922

@@ -38,8 +41,8 @@ export async function POST(request: NextRequest) {
3841
try {
3942
// Use appropriate handler based on path and environment
4043
const result =
41-
isS3Path(filePath) || USE_S3_STORAGE
42-
? await handleS3FileDelete(filePath)
44+
isCloudPath(filePath) || isUsingCloudStorage()
45+
? await handleCloudFileDelete(filePath)
4346
: await handleLocalFileDelete(filePath)
4447

4548
// Return success response
@@ -57,24 +60,24 @@ export async function POST(request: NextRequest) {
5760
}
5861

5962
/**
60-
* Handle S3 file deletion
63+
* Handle cloud file deletion (S3 or Azure Blob)
6164
*/
62-
async function handleS3FileDelete(filePath: string) {
63-
// Extract the S3 key from the path
64-
const s3Key = extractS3Key(filePath)
65-
logger.info(`Deleting file from S3: ${s3Key}`)
65+
async function handleCloudFileDelete(filePath: string) {
66+
// Extract the key from the path (works for both S3 and Blob paths)
67+
const key = extractCloudKey(filePath)
68+
logger.info(`Deleting file from cloud storage: ${key}`)
6669

6770
try {
68-
// Delete from S3
69-
await deleteFromS3(s3Key)
70-
logger.info(`File successfully deleted from S3: ${s3Key}`)
71+
// Delete from cloud storage using abstraction layer
72+
await deleteFile(key)
73+
logger.info(`File successfully deleted from cloud storage: ${key}`)
7174

7275
return {
7376
success: true as const,
74-
message: 'File deleted successfully from S3',
77+
message: 'File deleted successfully from cloud storage',
7578
}
7679
} catch (error) {
77-
logger.error('Error deleting file from S3:', error)
80+
logger.error('Error deleting file from cloud storage:', error)
7881
throw error
7982
}
8083
}
@@ -83,30 +86,52 @@ async function handleS3FileDelete(filePath: string) {
8386
* Handle local file deletion
8487
*/
8588
async function handleLocalFileDelete(filePath: string) {
86-
// Extract the filename from the path
8789
const filename = extractFilename(filePath)
88-
logger.info('Extracted filename for deletion:', filename)
89-
9090
const fullPath = join(UPLOAD_DIR, filename)
91-
logger.info('Full file path for deletion:', fullPath)
9291

93-
// Check if file exists
92+
logger.info(`Deleting local file: ${fullPath}`)
93+
9494
if (!existsSync(fullPath)) {
95-
logger.info(`File not found for deletion at path: ${fullPath}`)
95+
logger.info(`File not found, but that's okay: ${fullPath}`)
9696
return {
9797
success: true as const,
9898
message: "File not found, but that's okay",
9999
}
100100
}
101101

102-
// Delete the file
103-
await unlink(fullPath)
104-
logger.info(`File successfully deleted: ${fullPath}`)
102+
try {
103+
await unlink(fullPath)
104+
logger.info(`File successfully deleted: ${fullPath}`)
105+
106+
return {
107+
success: true as const,
108+
message: 'File deleted successfully',
109+
}
110+
} catch (error) {
111+
logger.error('Error deleting local file:', error)
112+
throw error
113+
}
114+
}
115+
116+
/**
117+
* Extract cloud storage key from file path (works for both S3 and Blob)
118+
*/
119+
function extractCloudKey(filePath: string): string {
120+
if (isS3Path(filePath)) {
121+
return extractS3Key(filePath)
122+
}
123+
124+
if (isBlobPath(filePath)) {
125+
return extractBlobKey(filePath)
126+
}
105127

106-
return {
107-
success: true as const,
108-
message: 'File deleted successfully',
128+
// Backwards-compatibility: allow generic paths like "/api/files/serve/<key>"
129+
if (filePath.startsWith('/api/files/serve/')) {
130+
return decodeURIComponent(filePath.substring('/api/files/serve/'.length))
109131
}
132+
133+
// As a last resort assume the incoming string is already a raw key.
134+
return filePath
110135
}
111136

112137
/**

0 commit comments

Comments
 (0)