Skip to content

Commit 6f32aea

Browse files
authored
feat(kb): added support for local file storage for knowledgebase (#1738)
* feat(kb): added support for local file storage for knowledgebase * updated tests * ack PR comments * added back env example
1 parent 98e9849 commit 6f32aea

File tree

5 files changed

+115
-23
lines changed

5 files changed

+115
-23
lines changed
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,4 @@ INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to gen
2020
# If left commented out, emails will be logged to console instead
2121

2222
# Local AI Models (Optional)
23-
# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models
24-
23+
# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,24 @@ export async function POST(request: NextRequest) {
121121
}
122122

123123
if (!isUsingCloudStorage()) {
124-
return NextResponse.json(
125-
{ error: 'Direct uploads are only available when cloud storage is enabled' },
126-
{ status: 400 }
124+
logger.info(
125+
`Local storage detected - batch presigned URLs not available, client will use API fallback`
127126
)
127+
return NextResponse.json({
128+
files: files.map((file) => ({
129+
fileName: file.fileName,
130+
presignedUrl: '', // Empty URL signals fallback to API upload
131+
fileInfo: {
132+
path: '',
133+
key: '',
134+
name: file.fileName,
135+
size: file.fileSize,
136+
type: file.contentType,
137+
},
138+
directUploadSupported: false,
139+
})),
140+
directUploadSupported: false,
141+
})
128142
}
129143

130144
const storageProvider = getStorageProvider()

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe('/api/files/presigned', () => {
2525
})
2626

2727
describe('POST', () => {
28-
it('should return error when cloud storage is not enabled', async () => {
28+
it('should return graceful fallback response when cloud storage is not enabled', async () => {
2929
setupFileApiMocks({
3030
cloudEnabled: false,
3131
storageProvider: 's3',
@@ -45,10 +45,14 @@ describe('/api/files/presigned', () => {
4545
const response = await POST(request)
4646
const data = await response.json()
4747

48-
expect(response.status).toBe(500)
49-
expect(data.error).toBe('Direct uploads are only available when cloud storage is enabled')
50-
expect(data.code).toBe('STORAGE_CONFIG_ERROR')
48+
expect(response.status).toBe(200)
5149
expect(data.directUploadSupported).toBe(false)
50+
expect(data.presignedUrl).toBe('')
51+
expect(data.fileName).toBe('test.txt')
52+
expect(data.fileInfo).toBeDefined()
53+
expect(data.fileInfo.name).toBe('test.txt')
54+
expect(data.fileInfo.size).toBe(1024)
55+
expect(data.fileInfo.type).toBe('text/plain')
5256
})
5357

5458
it('should return error when fileName is missing', async () => {

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,21 @@ export async function POST(request: NextRequest) {
141141
}
142142

143143
if (!isUsingCloudStorage()) {
144-
throw new StorageConfigError(
145-
'Direct uploads are only available when cloud storage is enabled'
144+
logger.info(
145+
`Local storage detected - presigned URL not available for ${fileName}, client will use API fallback`
146146
)
147+
return NextResponse.json({
148+
fileName,
149+
presignedUrl: '', // Empty URL signals fallback to API upload
150+
fileInfo: {
151+
path: '',
152+
key: '',
153+
name: fileName,
154+
size: fileSize,
155+
type: contentType,
156+
},
157+
directUploadSupported: false,
158+
})
147159
}
148160

149161
const storageProvider = getStorageProvider()

apps/sim/lib/uploads/storage-client.ts

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,34 @@ export async function uploadFile(
9292
return uploadToS3(file, fileName, contentType, configOrSize)
9393
}
9494

95-
throw new Error(
96-
'No storage provider configured. Set Azure credentials (AZURE_CONNECTION_STRING or AZURE_ACCOUNT_NAME + AZURE_ACCOUNT_KEY) or configure AWS credentials for S3.'
97-
)
95+
logger.info(`Uploading file to local storage: ${fileName}`)
96+
const { writeFile } = await import('fs/promises')
97+
const { join } = await import('path')
98+
const { v4: uuidv4 } = await import('uuid')
99+
const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/setup.server')
100+
101+
const safeFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_').replace(/\.\./g, '')
102+
const uniqueKey = `${uuidv4()}-${safeFileName}`
103+
const filePath = join(UPLOAD_DIR_SERVER, uniqueKey)
104+
105+
try {
106+
await writeFile(filePath, file)
107+
} catch (error) {
108+
logger.error(`Failed to write file to local storage: ${fileName}`, error)
109+
throw new Error(
110+
`Failed to write file to local storage: ${error instanceof Error ? error.message : 'Unknown error'}`
111+
)
112+
}
113+
114+
const fileSize = typeof configOrSize === 'number' ? configOrSize : size || file.length
115+
116+
return {
117+
path: `/api/files/serve/${uniqueKey}`,
118+
key: uniqueKey,
119+
name: fileName,
120+
size: fileSize,
121+
type: contentType,
122+
}
98123
}
99124

100125
/**
@@ -144,9 +169,28 @@ export async function downloadFile(
144169
return downloadFromS3(key)
145170
}
146171

147-
throw new Error(
148-
'No storage provider configured. Set Azure credentials (AZURE_CONNECTION_STRING or AZURE_ACCOUNT_NAME + AZURE_ACCOUNT_KEY) or configure AWS credentials for S3.'
149-
)
172+
logger.info(`Downloading file from local storage: ${key}`)
173+
const { readFile } = await import('fs/promises')
174+
const { join, resolve, sep } = await import('path')
175+
const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/setup.server')
176+
177+
const safeKey = key.replace(/\.\./g, '').replace(/[/\\]/g, '')
178+
const filePath = join(UPLOAD_DIR_SERVER, safeKey)
179+
180+
const resolvedPath = resolve(filePath)
181+
const allowedDir = resolve(UPLOAD_DIR_SERVER)
182+
if (!resolvedPath.startsWith(allowedDir + sep) && resolvedPath !== allowedDir) {
183+
throw new Error('Invalid file path')
184+
}
185+
186+
try {
187+
return await readFile(filePath)
188+
} catch (error) {
189+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
190+
throw new Error(`File not found: ${key}`)
191+
}
192+
throw error
193+
}
150194
}
151195

152196
/**
@@ -166,9 +210,29 @@ export async function deleteFile(key: string): Promise<void> {
166210
return deleteFromS3(key)
167211
}
168212

169-
throw new Error(
170-
'No storage provider configured. Set Azure credentials (AZURE_CONNECTION_STRING or AZURE_ACCOUNT_NAME + AZURE_ACCOUNT_KEY) or configure AWS credentials for S3.'
171-
)
213+
logger.info(`Deleting file from local storage: ${key}`)
214+
const { unlink } = await import('fs/promises')
215+
const { join, resolve, sep } = await import('path')
216+
const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/setup.server')
217+
218+
const safeKey = key.replace(/\.\./g, '').replace(/[/\\]/g, '')
219+
const filePath = join(UPLOAD_DIR_SERVER, safeKey)
220+
221+
const resolvedPath = resolve(filePath)
222+
const allowedDir = resolve(UPLOAD_DIR_SERVER)
223+
if (!resolvedPath.startsWith(allowedDir + sep) && resolvedPath !== allowedDir) {
224+
throw new Error('Invalid file path')
225+
}
226+
227+
try {
228+
await unlink(filePath)
229+
} catch (error) {
230+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
231+
logger.warn(`File not found during deletion: ${key}`)
232+
return
233+
}
234+
throw error
235+
}
172236
}
173237

174238
/**
@@ -190,9 +254,8 @@ export async function getPresignedUrl(key: string, expiresIn = 3600): Promise<st
190254
return getS3PresignedUrl(key, expiresIn)
191255
}
192256

193-
throw new Error(
194-
'No storage provider configured. Set Azure credentials (AZURE_CONNECTION_STRING or AZURE_ACCOUNT_NAME + AZURE_ACCOUNT_KEY) or configure AWS credentials for S3.'
195-
)
257+
logger.info(`Generating serve path for local storage: ${key}`)
258+
return `/api/files/serve/${encodeURIComponent(key)}`
196259
}
197260

198261
/**

0 commit comments

Comments
 (0)