chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,42 @@
import type { StorageContext } from '@/lib/uploads/shared/types'
/**
* Server-proxied fallback used only when cloud storage isn't configured (local dev).
* Production always takes the presigned PUT path.
*/
export async function uploadViaApiFallback(
file: File,
context: StorageContext,
workspaceId?: string
): Promise<{ path: string; key?: string }> {
const formData = new FormData()
formData.append('file', file)
formData.append('context', context)
if (workspaceId) {
formData.append('workspaceId', workspaceId)
}
// boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson
const response = await fetch('/api/files/upload', { method: 'POST', body: formData })
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as {
message?: string
error?: string
}
throw new Error(
errorData.message || errorData.error || `Failed to upload file: ${response.status}`
)
}
const data = (await response.json()) as {
fileInfo?: { path?: string; key?: string }
path?: string
key?: string
url?: string
}
const path = data.fileInfo?.path ?? data.path ?? data.url
const key = data.fileInfo?.key ?? data.key
if (!path) {
throw new Error('Invalid upload response: missing path')
}
return { path, key }
}
@@ -0,0 +1,225 @@
/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
DirectUploadError,
type PresignedUploadInfo,
runUploadStrategy,
} from '@/lib/uploads/client/direct-upload'
const ONE_MB = 1024 * 1024
const LARGE_THRESHOLD = 50 * ONE_MB
const makeFile = (size: number, name = 'test.bin', type = 'application/octet-stream'): File => {
const file = new File([new Uint8Array(0)], name, { type })
Object.defineProperty(file, 'size', { value: size })
return file
}
const presigned = (overrides?: Partial<PresignedUploadInfo>): PresignedUploadInfo => ({
fileName: 'test.bin',
presignedUrl: 'https://s3/presigned',
fileInfo: {
path: '/api/files/serve/test',
key: 'workspace/ws-1/test.bin',
name: 'test.bin',
size: ONE_MB,
type: 'application/octet-stream',
},
uploadHeaders: undefined,
directUploadSupported: true,
...overrides,
})
class MockXHR {
static instances: MockXHR[] = []
upload = { addEventListener: vi.fn() }
status = 200
statusText = 'OK'
private listeners: Record<string, Array<() => void>> = {}
open = vi.fn()
setRequestHeader = vi.fn()
abort = vi.fn()
send = vi.fn(() => {
queueMicrotask(() => this.listeners.load?.forEach((cb) => cb()))
})
addEventListener = (event: string, cb: () => void) => {
;(this.listeners[event] ??= []).push(cb)
}
removeEventListener = vi.fn()
constructor() {
MockXHR.instances.push(this)
}
}
describe('runUploadStrategy', () => {
let originalXHR: typeof XMLHttpRequest
beforeEach(() => {
MockXHR.instances = []
originalXHR = globalThis.XMLHttpRequest
globalThis.XMLHttpRequest = MockXHR as unknown as typeof XMLHttpRequest
})
afterEach(() => {
globalThis.XMLHttpRequest = originalXHR
vi.restoreAllMocks()
})
it('uses presigned PUT for files at or below the multipart threshold', async () => {
const file = makeFile(LARGE_THRESHOLD)
const result = await runUploadStrategy({
file,
workspaceId: 'ws-1',
context: 'workspace',
presignedOverride: presigned(),
})
expect(result.key).toBe('workspace/ws-1/test.bin')
expect(MockXHR.instances).toHaveLength(1)
expect(MockXHR.instances[0].open).toHaveBeenCalledWith('PUT', 'https://s3/presigned')
})
it('throws FALLBACK_REQUIRED when server signals no cloud storage', async () => {
const file = makeFile(ONE_MB)
await expect(
runUploadStrategy({
file,
workspaceId: 'ws-1',
context: 'workspace',
presignedOverride: presigned({ presignedUrl: '', directUploadSupported: false }),
})
).rejects.toMatchObject({
name: 'DirectUploadError',
code: 'FALLBACK_REQUIRED',
})
})
it('takes the multipart path for files larger than the threshold and posts unified parts', async () => {
const file = makeFile(LARGE_THRESHOLD + ONE_MB)
const calls: Array<{ url: string; body: unknown }> = []
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString()
const rawBody = init?.body
const body = typeof rawBody === 'string' ? JSON.parse(rawBody) : undefined
calls.push({ url, body })
if (url.includes('action=initiate')) {
return new Response(
JSON.stringify({ uploadId: 'u1', key: 'workspace/ws-1/big.bin', uploadToken: 't' }),
{ status: 200 }
)
}
if (url.includes('action=get-part-urls')) {
return new Response(
JSON.stringify({
presignedUrls: [
{ partNumber: 1, url: 'https://s3/part1' },
{ partNumber: 2, url: 'https://s3/part2' },
],
}),
{ status: 200 }
)
}
if (url.startsWith('https://s3/part')) {
return new Response(null, { status: 200, headers: { ETag: '"etag-x"' } })
}
if (url.includes('action=complete')) {
return new Response(JSON.stringify({ path: '/api/files/serve/big' }), { status: 200 })
}
throw new Error(`unexpected url ${url}`)
})
vi.stubGlobal('fetch', fetchMock)
const result = await runUploadStrategy({
file,
workspaceId: 'ws-1',
context: 'workspace',
})
expect(result.path).toBe('/api/files/serve/big')
const completeCall = calls.find((c) => c.url.includes('action=complete'))!
expect(completeCall.body).toMatchObject({
uploadToken: 't',
parts: [
{ partNumber: 1, etag: 'etag-x' },
{ partNumber: 2, etag: 'etag-x' },
],
})
})
it('rejects with ABORTED when signal is already aborted before PUT begins', async () => {
const file = makeFile(ONE_MB)
const controller = new AbortController()
controller.abort()
await expect(
runUploadStrategy({
file,
workspaceId: 'ws-1',
context: 'workspace',
presignedOverride: presigned(),
signal: controller.signal,
})
).rejects.toMatchObject({ name: 'DirectUploadError', code: 'ABORTED' })
})
it('fires action=abort when the multipart complete call fails', async () => {
const file = makeFile(LARGE_THRESHOLD + ONE_MB)
const calls: Array<{ url: string }> = []
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
calls.push({ url })
if (url.includes('action=initiate')) {
return new Response(
JSON.stringify({ uploadId: 'u1', key: 'workspace/ws-1/big.bin', uploadToken: 't' }),
{ status: 200 }
)
}
if (url.includes('action=get-part-urls')) {
return new Response(
JSON.stringify({
presignedUrls: [
{ partNumber: 1, url: 'https://s3/part1' },
{ partNumber: 2, url: 'https://s3/part2' },
],
}),
{ status: 200 }
)
}
if (url.startsWith('https://s3/part')) {
return new Response(null, { status: 200, headers: { ETag: '"etag-x"' } })
}
if (url.includes('action=complete')) {
return new Response(JSON.stringify({ error: 'kaboom' }), { status: 500 })
}
if (url.includes('action=abort')) {
return new Response(null, { status: 200 })
}
throw new Error(`unexpected url ${url}`)
})
vi.stubGlobal('fetch', fetchMock)
await expect(
runUploadStrategy({ file, workspaceId: 'ws-1', context: 'workspace' })
).rejects.toBeInstanceOf(DirectUploadError)
expect(calls.some((c) => c.url.includes('action=abort'))).toBe(true)
})
it('throws when neither presignedEndpoint nor presignedOverride is supplied', async () => {
const file = makeFile(ONE_MB)
await expect(
runUploadStrategy({ file, workspaceId: 'ws-1', context: 'workspace' })
).rejects.toBeInstanceOf(DirectUploadError)
})
})
@@ -0,0 +1,632 @@
import { createLogger } from '@sim/logger'
import { sleep } from '@sim/utils/helpers'
import { getFileContentType, isAbortError } from '@/lib/uploads/utils/file-utils'
const logger = createLogger('DirectUpload')
const CHUNK_SIZE = 8 * 1024 * 1024
export const LARGE_FILE_THRESHOLD = 50 * 1024 * 1024
const BASE_TIMEOUT_MS = 2 * 60 * 1000
const TIMEOUT_PER_MB_MS = 1500
const MAX_TIMEOUT_MS = 10 * 60 * 1000
export const MULTIPART_PART_CONCURRENCY = 3
export const MULTIPART_MAX_RETRIES = 3
export const MULTIPART_RETRY_DELAY_MS = 2000
export const MULTIPART_RETRY_BACKOFF = 2
export const WHOLE_FILE_PARALLEL_UPLOADS = 3
interface PresignedFileInfo {
path: string
key: string
name: string
size: number
type: string
}
export interface PresignedUploadInfo {
fileName: string
presignedUrl: string
fileInfo: PresignedFileInfo
uploadHeaders?: Record<string, string>
directUploadSupported: boolean
}
export interface UploadStrategyResult {
key: string
path: string
name: string
size: number
contentType: string
}
export interface UploadProgressEvent {
loaded: number
total: number
percent: number
}
export type DirectUploadErrorCode =
| 'PRESIGNED_URL_ERROR'
| 'DIRECT_UPLOAD_ERROR'
| 'MULTIPART_ERROR'
| 'ABORTED'
| 'FALLBACK_REQUIRED'
export class DirectUploadError extends Error {
constructor(
message: string,
public code: DirectUploadErrorCode,
public details?: unknown,
public status?: number
) {
super(message)
this.name = 'DirectUploadError'
}
}
/**
* Transport-level upload errors worth retrying at the outer level: timeouts,
* network failures, and 5xx from the storage backend. Excludes deterministic
* client failures (4xx, `PRESIGNED_URL_ERROR`, `FALLBACK_REQUIRED`) and aborts.
*/
export const isTransientUploadError = (error: unknown): boolean => {
if (!(error instanceof DirectUploadError)) return false
if (error.code !== 'DIRECT_UPLOAD_ERROR' && error.code !== 'MULTIPART_ERROR') return false
if (error.status === undefined) return true
return error.status >= 500 && error.status < 600
}
export const calculateUploadTimeoutMs = (fileSize: number): number => {
const sizeInMb = fileSize / (1024 * 1024)
const dynamicBudget = BASE_TIMEOUT_MS + sizeInMb * TIMEOUT_PER_MB_MS
return Math.min(dynamicBudget, MAX_TIMEOUT_MS)
}
/**
* Run `worker` over `items` with at most `limit` concurrent invocations.
* Returns a settled result per item (never rejects), so callers can handle
* partial failures explicitly.
*/
export const runWithConcurrency = async <T, R>(
items: T[],
limit: number,
worker: (item: T, index: number) => Promise<R>
): Promise<Array<PromiseSettledResult<R>>> => {
const results: Array<PromiseSettledResult<R>> = Array(items.length)
if (items.length === 0) return results
const concurrency = Math.max(1, Math.min(limit, items.length))
let nextIndex = 0
const runners = Array.from({ length: concurrency }, async () => {
while (true) {
const currentIndex = nextIndex++
if (currentIndex >= items.length) break
try {
const value = await worker(items[currentIndex], currentIndex)
results[currentIndex] = { status: 'fulfilled', value }
} catch (error) {
results[currentIndex] = { status: 'rejected', reason: error }
}
}
})
await Promise.all(runners)
return results
}
/**
* Normalize a presigned-upload server response into a {@link PresignedUploadInfo}.
* Accepts both single (`/api/files/presigned`) and batch entry shapes, tolerates
* `presignedUrl` vs `uploadUrl` aliases, and short-circuits when the server
* signals no cloud storage (`directUploadSupported: false`) so callers can fall
* back to a server-proxied upload path.
*
* @throws {@link DirectUploadError} with code `PRESIGNED_URL_ERROR` if the
* response is missing a presigned URL or `fileInfo.path`.
*/
export const normalizePresignedData = (data: unknown, context: string): PresignedUploadInfo => {
const d = (data ?? {}) as Record<string, unknown>
const presignedUrl = (d.presignedUrl as string) || (d.uploadUrl as string) || ''
const fileInfo = d.fileInfo as Record<string, unknown> | undefined
const directUploadSupported = d.directUploadSupported !== false
if (!directUploadSupported) {
return {
fileName: (d.fileName as string) || context,
presignedUrl: '',
fileInfo: { path: '', key: '', name: context, size: 0, type: '' },
directUploadSupported: false,
}
}
if (!presignedUrl || !fileInfo?.path) {
throw new DirectUploadError(
`Invalid presigned response for ${context}`,
'PRESIGNED_URL_ERROR',
data
)
}
return {
fileName: (d.fileName as string) || (fileInfo.name as string) || context,
presignedUrl,
fileInfo: {
path: fileInfo.path as string,
key: (fileInfo.key as string) || '',
name: (fileInfo.name as string) || context,
size: (fileInfo.size as number) || (d.fileSize as number) || 0,
type: (fileInfo.type as string) || (d.contentType as string) || '',
},
uploadHeaders: (d.uploadHeaders as Record<string, string>) || undefined,
directUploadSupported: true,
}
}
interface GetPresignedOptions {
endpoint: string
file: File
body?: Record<string, unknown>
signal?: AbortSignal
}
/**
* Fetch a single presigned upload URL from a server endpoint that follows the
* `{ presignedUrl, fileInfo, uploadHeaders?, directUploadSupported }` contract.
*/
export const getPresignedUploadInfo = async (
opts: GetPresignedOptions
): Promise<PresignedUploadInfo> => {
const { endpoint, file, body, signal } = opts
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
contentType: getFileContentType(file),
fileSize: file.size,
...body,
}),
signal,
})
if (!response.ok) {
let errorDetails: unknown = null
try {
errorDetails = await response.json()
} catch {}
const serverMessage =
errorDetails != null &&
typeof errorDetails === 'object' &&
typeof (errorDetails as Record<string, unknown>).error === 'string'
? ((errorDetails as Record<string, unknown>).error as string)
: null
throw new DirectUploadError(
serverMessage ||
`Failed to get presigned URL for ${file.name}: ${response.status} ${response.statusText}`,
'PRESIGNED_URL_ERROR',
errorDetails
)
}
return normalizePresignedData(await response.json(), file.name)
}
interface UploadViaPutOptions {
file: File
presignedUrl: string
uploadHeaders?: Record<string, string>
signal?: AbortSignal
onProgress?: (event: UploadProgressEvent) => void
}
const uploadViaPresignedPut = (opts: UploadViaPutOptions): Promise<void> => {
const { file, presignedUrl, uploadHeaders, signal, onProgress } = opts
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
let isCompleted = false
const timeoutMs = calculateUploadTimeoutMs(file.size)
const timeoutId = setTimeout(() => {
if (isCompleted) return
isCompleted = true
signal?.removeEventListener('abort', abortHandler)
xhr.abort()
reject(new DirectUploadError(`Upload timeout for ${file.name}`, 'DIRECT_UPLOAD_ERROR'))
}, timeoutMs)
const abortHandler = () => {
if (isCompleted) return
isCompleted = true
clearTimeout(timeoutId)
xhr.abort()
reject(new DirectUploadError(`Upload aborted for ${file.name}`, 'ABORTED'))
}
if (signal) {
if (signal.aborted) {
abortHandler()
return
}
signal.addEventListener('abort', abortHandler)
}
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && !isCompleted) {
onProgress?.({
loaded: event.loaded,
total: event.total,
percent: Math.round((event.loaded / event.total) * 100),
})
}
})
xhr.addEventListener('load', () => {
if (isCompleted) return
isCompleted = true
clearTimeout(timeoutId)
signal?.removeEventListener('abort', abortHandler)
if (xhr.status >= 200 && xhr.status < 300) {
onProgress?.({ loaded: file.size, total: file.size, percent: 100 })
resolve()
} else {
reject(
new DirectUploadError(
`Direct upload failed for ${file.name}: ${xhr.status} ${xhr.statusText}`,
'DIRECT_UPLOAD_ERROR',
undefined,
xhr.status
)
)
}
})
xhr.addEventListener('error', () => {
if (isCompleted) return
isCompleted = true
clearTimeout(timeoutId)
signal?.removeEventListener('abort', abortHandler)
reject(new DirectUploadError(`Network error uploading ${file.name}`, 'DIRECT_UPLOAD_ERROR'))
})
xhr.open('PUT', presignedUrl)
xhr.setRequestHeader('Content-Type', getFileContentType(file))
if (uploadHeaders) {
for (const [key, value] of Object.entries(uploadHeaders)) {
xhr.setRequestHeader(key, value)
}
}
xhr.send(file)
})
}
interface MultipartUploadOptions {
file: File
workspaceId: string
context:
| 'workspace'
| 'knowledge-base'
| 'mothership'
| 'profile-pictures'
| 'workspace-logos'
| 'execution'
workflowId?: string
executionId?: string
signal?: AbortSignal
onProgress?: (event: UploadProgressEvent) => void
}
interface CompletedPart {
partNumber: number
etag?: string
}
interface PartUrl {
partNumber: number
url: string
}
const uploadViaMultipart = async (
opts: MultipartUploadOptions
): Promise<{ key: string; path: string }> => {
const { file, workspaceId, context, workflowId, executionId, signal, onProgress } = opts
// boundary-raw-fetch: multipart upload control plane uses action query strings; client lifecycle (initiate/get-part-urls/complete/abort) is sequenced manually and not modeled by a single contract
const initiateResponse = await fetch('/api/files/multipart?action=initiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
contentType: getFileContentType(file),
fileSize: file.size,
workspaceId,
context,
...(workflowId ? { workflowId } : {}),
...(executionId ? { executionId } : {}),
}),
signal,
})
if (!initiateResponse.ok) {
let errorBody: { error?: string } | null = null
try {
errorBody = (await initiateResponse.clone().json()) as { error?: string }
} catch {}
if (
initiateResponse.status === 400 &&
typeof errorBody?.error === 'string' &&
errorBody.error.toLowerCase().includes('cloud storage')
) {
throw new DirectUploadError(
'Server signaled fallback to API upload',
'FALLBACK_REQUIRED',
errorBody
)
}
throw new DirectUploadError(
`Failed to initiate multipart upload: ${initiateResponse.statusText}`,
'MULTIPART_ERROR',
undefined,
initiateResponse.status
)
}
const { key, uploadToken } = (await initiateResponse.json()) as {
uploadId: string
key: string
uploadToken: string
}
const numParts = Math.ceil(file.size / CHUNK_SIZE)
const partNumbers = Array.from({ length: numParts }, (_, i) => i + 1)
const abortMultipart = async () => {
try {
// boundary-raw-fetch: fire-and-forget abort during multipart cleanup; intentionally avoids contract response parsing so cleanup cannot mask the original error
await fetch('/api/files/multipart?action=abort', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadToken }),
})
} catch (err) {
logger.warn('Failed to abort multipart upload:', err)
}
}
let presignedUrls: PartUrl[]
try {
// boundary-raw-fetch: multipart upload control plane uses action query strings; sequenced with initiate/complete/abort outside the contract layer
const partUrlsResponse = await fetch('/api/files/multipart?action=get-part-urls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadToken, partNumbers }),
signal,
})
if (!partUrlsResponse.ok) {
throw new DirectUploadError(
`Failed to get part URLs: ${partUrlsResponse.statusText}`,
'MULTIPART_ERROR',
undefined,
partUrlsResponse.status
)
}
;({ presignedUrls } = (await partUrlsResponse.json()) as { presignedUrls: PartUrl[] })
} catch (err) {
await abortMultipart()
throw err
}
const completedBytes = new Array<number>(numParts).fill(0)
const reportProgress = () => {
const loaded = completedBytes.reduce((a, b) => a + b, 0)
onProgress?.({
loaded,
total: file.size,
percent: Math.min(100, Math.round((loaded / file.size) * 100)),
})
}
const uploadedParts: CompletedPart[] = []
try {
const uploadPart = async ({ partNumber, url }: PartUrl): Promise<CompletedPart> => {
const start = (partNumber - 1) * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, file.size)
const chunk = file.slice(start, end)
for (let attempt = 0; attempt <= MULTIPART_MAX_RETRIES; attempt++) {
try {
const partResponse = await fetch(url, {
method: 'PUT',
body: chunk,
signal,
headers: { 'Content-Type': getFileContentType(file) },
})
if (!partResponse.ok) {
throw new DirectUploadError(
`Failed to upload part ${partNumber}: ${partResponse.statusText}`,
'MULTIPART_ERROR',
undefined,
partResponse.status
)
}
const etag = partResponse.headers.get('ETag') || undefined
completedBytes[partNumber - 1] = end - start
reportProgress()
return { partNumber, etag: etag?.replace(/"/g, '') }
} catch (partError) {
const isClientError =
partError instanceof DirectUploadError &&
partError.status !== undefined &&
partError.status >= 400 &&
partError.status < 500
if (isAbortError(partError) || isClientError || attempt >= MULTIPART_MAX_RETRIES) {
throw partError
}
const delay = MULTIPART_RETRY_DELAY_MS * MULTIPART_RETRY_BACKOFF ** attempt
logger.warn(
`Part ${partNumber} failed (attempt ${attempt + 1}), retrying in ${Math.round(delay / 1000)}s`
)
await sleep(delay)
}
}
throw new DirectUploadError(`Retries exhausted for part ${partNumber}`, 'MULTIPART_ERROR')
}
const partResults = await runWithConcurrency(
presignedUrls,
MULTIPART_PART_CONCURRENCY,
uploadPart
)
for (const result of partResults) {
if (result?.status === 'fulfilled') {
uploadedParts.push(result.value)
} else if (result?.status === 'rejected') {
throw result.reason
}
}
} catch (error) {
await abortMultipart()
throw error
}
let path: string
try {
// boundary-raw-fetch: multipart upload control plane uses action query strings; sequenced with initiate/get-part-urls/abort outside the contract layer
const completeResponse = await fetch('/api/files/multipart?action=complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadToken, parts: uploadedParts }),
signal,
})
if (!completeResponse.ok) {
throw new DirectUploadError(
`Failed to complete multipart upload: ${completeResponse.statusText}`,
'MULTIPART_ERROR',
undefined,
completeResponse.status
)
}
;({ path } = (await completeResponse.json()) as { path: string })
} catch (err) {
await abortMultipart()
throw err
}
return { key, path }
}
export interface RunUploadStrategyOptions {
file: File
workspaceId: string
context:
| 'workspace'
| 'knowledge-base'
| 'mothership'
| 'profile-pictures'
| 'workspace-logos'
| 'execution'
/** Endpoint to mint a presigned PUT URL. Required unless `presignedOverride` is provided. */
presignedEndpoint?: string
/** Pre-fetched presigned data (e.g. from a batch endpoint). Skips per-file fetch. */
presignedOverride?: PresignedUploadInfo
/** Extra JSON body fields for the presigned endpoint. */
presignedBody?: Record<string, unknown>
/** Required when context is `execution`; forwarded to the multipart route to scope the storage key. */
workflowId?: string
/** Required when context is `execution`; forwarded to the multipart route to scope the storage key. */
executionId?: string
signal?: AbortSignal
onProgress?: (event: UploadProgressEvent) => void
}
/**
* Strategy ladder for client-side uploads:
* - Files larger than {@link LARGE_FILE_THRESHOLD} use multipart S3/Blob with chunked PUTs.
* - Smaller files use a presigned PUT URL (fetched per-file, or supplied via
* `presignedOverride` for batched flows like KB).
* - If the server signals no cloud storage is configured, a {@link DirectUploadError}
* with code `FALLBACK_REQUIRED` is thrown so callers can fall back to a server-proxied path.
*/
export const runUploadStrategy = async (
opts: RunUploadStrategyOptions
): Promise<UploadStrategyResult> => {
const {
file,
presignedEndpoint,
presignedOverride,
presignedBody,
workspaceId,
context,
workflowId,
executionId,
signal,
onProgress,
} = opts
const contentType = getFileContentType(file)
if (presignedOverride && !presignedOverride.directUploadSupported) {
throw new DirectUploadError('Server signaled fallback to API upload', 'FALLBACK_REQUIRED')
}
if (file.size > LARGE_FILE_THRESHOLD) {
const { key, path } = await uploadViaMultipart({
file,
workspaceId,
context,
workflowId,
executionId,
signal,
onProgress,
})
return { key, path, name: file.name, size: file.size, contentType }
}
let presigned: PresignedUploadInfo
if (presignedOverride) {
presigned = presignedOverride
} else {
if (!presignedEndpoint) {
throw new DirectUploadError(
'runUploadStrategy requires either presignedEndpoint or presignedOverride',
'PRESIGNED_URL_ERROR'
)
}
presigned = await getPresignedUploadInfo({
endpoint: presignedEndpoint,
file,
body: presignedBody,
signal,
})
}
if (!presigned.directUploadSupported) {
throw new DirectUploadError('Server signaled fallback to API upload', 'FALLBACK_REQUIRED')
}
await uploadViaPresignedPut({
file,
presignedUrl: presigned.presignedUrl,
uploadHeaders: presigned.uploadHeaders,
signal,
onProgress,
})
return {
key: presigned.fileInfo.key,
path: presigned.fileInfo.path,
name: file.name,
size: file.size,
contentType,
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
export async function triggerFileDownload(record: WorkspaceFileRecord): Promise<void> {
const isMarkdown =
record.type === 'text/markdown' ||
record.type === 'text/x-markdown' ||
/\.(?:md|markdown)$/i.test(record.name)
const url = isMarkdown
? `/api/files/export/${encodeURIComponent(record.id)}`
: `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}`
const response = await fetch(url, { cache: 'no-store' })
if (!response.ok) throw new Error(`Failed to download file: ${response.statusText}`)
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
a.download =
response.headers.get('Content-Disposition')?.match(/filename="([^"]+)"/)?.[1] ?? record.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(objectUrl)
}
+299
View File
@@ -0,0 +1,299 @@
import { env, envBoolean } from '@/lib/core/config/env'
import type { StorageConfig, StorageContext } from '@/lib/uploads/shared/types'
export type { StorageConfig, StorageContext } from '@/lib/uploads/shared/types'
export const UPLOAD_DIR = '/uploads'
const hasS3Config = !!(env.S3_BUCKET_NAME && env.AWS_REGION)
export const hasBlobConfig = !!(
env.AZURE_STORAGE_CONTAINER_NAME &&
((env.AZURE_ACCOUNT_NAME && env.AZURE_ACCOUNT_KEY) || env.AZURE_CONNECTION_STRING)
)
export const USE_BLOB_STORAGE = hasBlobConfig
export const USE_S3_STORAGE = hasS3Config && !USE_BLOB_STORAGE
export const S3_CONFIG = {
bucket: env.S3_BUCKET_NAME || '',
region: env.AWS_REGION || '',
/**
* Custom endpoint for S3-compatible providers (Cloudflare R2, MinIO, Backblaze B2).
* Unset means the AWS SDK derives the host from `region`, targeting AWS S3.
* This is trusted operator configuration (not user input), so it is passed
* through verbatim — `http://` and private hosts are allowed for on-prem MinIO.
*/
endpoint: env.S3_ENDPOINT || undefined,
/** Path-style addressing — required by MinIO/Ceph RGW; AWS S3 and R2 use the default `false`. */
forcePathStyle: envBoolean(env.S3_FORCE_PATH_STYLE) ?? false,
}
export const BLOB_CONFIG = {
accountName: env.AZURE_ACCOUNT_NAME || '',
accountKey: env.AZURE_ACCOUNT_KEY || '',
connectionString: env.AZURE_CONNECTION_STRING || '',
containerName: env.AZURE_STORAGE_CONTAINER_NAME || '',
}
export const S3_KB_CONFIG = {
bucket: env.S3_KB_BUCKET_NAME || '',
region: env.AWS_REGION || '',
}
export const S3_EXECUTION_FILES_CONFIG = {
bucket: env.S3_EXECUTION_FILES_BUCKET_NAME || 'sim-execution-files',
region: env.AWS_REGION || '',
}
export const BLOB_KB_CONFIG = {
accountName: env.AZURE_ACCOUNT_NAME || '',
accountKey: env.AZURE_ACCOUNT_KEY || '',
connectionString: env.AZURE_CONNECTION_STRING || '',
containerName: env.AZURE_STORAGE_KB_CONTAINER_NAME || '',
}
export const BLOB_EXECUTION_FILES_CONFIG = {
accountName: env.AZURE_ACCOUNT_NAME || '',
accountKey: env.AZURE_ACCOUNT_KEY || '',
connectionString: env.AZURE_CONNECTION_STRING || '',
containerName: env.AZURE_STORAGE_EXECUTION_FILES_CONTAINER_NAME || 'sim-execution-files',
}
export const S3_CHAT_CONFIG = {
bucket: env.S3_CHAT_BUCKET_NAME || '',
region: env.AWS_REGION || '',
}
export const BLOB_CHAT_CONFIG = {
accountName: env.AZURE_ACCOUNT_NAME || '',
accountKey: env.AZURE_ACCOUNT_KEY || '',
connectionString: env.AZURE_CONNECTION_STRING || '',
containerName: env.AZURE_STORAGE_CHAT_CONTAINER_NAME || '',
}
export const S3_COPILOT_CONFIG = {
bucket: env.S3_COPILOT_BUCKET_NAME || '',
region: env.AWS_REGION || '',
}
export const BLOB_COPILOT_CONFIG = {
accountName: env.AZURE_ACCOUNT_NAME || '',
accountKey: env.AZURE_ACCOUNT_KEY || '',
connectionString: env.AZURE_CONNECTION_STRING || '',
containerName: env.AZURE_STORAGE_COPILOT_CONTAINER_NAME || '',
}
export const S3_PROFILE_PICTURES_CONFIG = {
bucket: env.S3_PROFILE_PICTURES_BUCKET_NAME || '',
region: env.AWS_REGION || '',
}
export const BLOB_PROFILE_PICTURES_CONFIG = {
accountName: env.AZURE_ACCOUNT_NAME || '',
accountKey: env.AZURE_ACCOUNT_KEY || '',
connectionString: env.AZURE_CONNECTION_STRING || '',
containerName: env.AZURE_STORAGE_PROFILE_PICTURES_CONTAINER_NAME || '',
}
export const S3_OG_IMAGES_CONFIG = {
bucket: env.S3_OG_IMAGES_BUCKET_NAME || '',
region: env.AWS_REGION || '',
}
export const BLOB_OG_IMAGES_CONFIG = {
accountName: env.AZURE_ACCOUNT_NAME || '',
accountKey: env.AZURE_ACCOUNT_KEY || '',
connectionString: env.AZURE_CONNECTION_STRING || '',
containerName: env.AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME || '',
}
export const S3_WORKSPACE_LOGOS_CONFIG = {
bucket: env.S3_WORKSPACE_LOGOS_BUCKET_NAME || '',
region: env.AWS_REGION || '',
}
export const BLOB_WORKSPACE_LOGOS_CONFIG = {
accountName: env.AZURE_ACCOUNT_NAME || '',
accountKey: env.AZURE_ACCOUNT_KEY || '',
connectionString: env.AZURE_CONNECTION_STRING || '',
containerName: env.AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME || '',
}
/**
* Get the current storage provider as a human-readable string
*/
export function getStorageProvider(): 'Azure Blob' | 'S3' | 'Local' {
if (USE_BLOB_STORAGE) return 'Azure Blob'
if (USE_S3_STORAGE) return 'S3'
return 'Local'
}
/**
* Check if we're using any cloud storage (S3 or Blob)
*/
export function isUsingCloudStorage(): boolean {
return USE_S3_STORAGE || USE_BLOB_STORAGE
}
/**
* Get the appropriate storage configuration for a given context
*/
export function getStorageConfig(context: StorageContext): StorageConfig {
if (USE_BLOB_STORAGE) {
return getBlobConfig(context)
}
if (USE_S3_STORAGE) {
return getS3Config(context)
}
return {}
}
/**
* Get S3 configuration for a given context
*/
function getS3Config(context: StorageContext): StorageConfig {
switch (context) {
case 'knowledge-base':
return {
bucket: S3_KB_CONFIG.bucket,
region: S3_KB_CONFIG.region,
}
case 'chat':
return {
bucket: S3_CHAT_CONFIG.bucket,
region: S3_CHAT_CONFIG.region,
}
case 'copilot':
return {
bucket: S3_COPILOT_CONFIG.bucket,
region: S3_COPILOT_CONFIG.region,
}
case 'execution':
return {
bucket: S3_EXECUTION_FILES_CONFIG.bucket,
region: S3_EXECUTION_FILES_CONFIG.region,
}
case 'mothership':
case 'workspace':
return {
bucket: S3_CONFIG.bucket,
region: S3_CONFIG.region,
}
case 'profile-pictures':
return {
bucket: S3_PROFILE_PICTURES_CONFIG.bucket,
region: S3_PROFILE_PICTURES_CONFIG.region,
}
case 'og-images':
return {
bucket: S3_OG_IMAGES_CONFIG.bucket || S3_CONFIG.bucket,
region: S3_OG_IMAGES_CONFIG.region || S3_CONFIG.region,
}
case 'workspace-logos':
return {
bucket: S3_WORKSPACE_LOGOS_CONFIG.bucket || S3_CONFIG.bucket,
region: S3_WORKSPACE_LOGOS_CONFIG.region || S3_CONFIG.region,
}
default:
return {
bucket: S3_CONFIG.bucket,
region: S3_CONFIG.region,
}
}
}
/**
* Get Azure Blob configuration for a given context
*/
function getBlobConfig(context: StorageContext): StorageConfig {
switch (context) {
case 'knowledge-base':
return {
accountName: BLOB_KB_CONFIG.accountName,
accountKey: BLOB_KB_CONFIG.accountKey,
connectionString: BLOB_KB_CONFIG.connectionString,
containerName: BLOB_KB_CONFIG.containerName,
}
case 'chat':
return {
accountName: BLOB_CHAT_CONFIG.accountName,
accountKey: BLOB_CHAT_CONFIG.accountKey,
connectionString: BLOB_CHAT_CONFIG.connectionString,
containerName: BLOB_CHAT_CONFIG.containerName,
}
case 'copilot':
return {
accountName: BLOB_COPILOT_CONFIG.accountName,
accountKey: BLOB_COPILOT_CONFIG.accountKey,
connectionString: BLOB_COPILOT_CONFIG.connectionString,
containerName: BLOB_COPILOT_CONFIG.containerName,
}
case 'execution':
return {
accountName: BLOB_EXECUTION_FILES_CONFIG.accountName,
accountKey: BLOB_EXECUTION_FILES_CONFIG.accountKey,
connectionString: BLOB_EXECUTION_FILES_CONFIG.connectionString,
containerName: BLOB_EXECUTION_FILES_CONFIG.containerName,
}
case 'mothership':
case 'workspace':
return {
accountName: BLOB_CONFIG.accountName,
accountKey: BLOB_CONFIG.accountKey,
connectionString: BLOB_CONFIG.connectionString,
containerName: BLOB_CONFIG.containerName,
}
case 'profile-pictures':
return {
accountName: BLOB_PROFILE_PICTURES_CONFIG.accountName,
accountKey: BLOB_PROFILE_PICTURES_CONFIG.accountKey,
connectionString: BLOB_PROFILE_PICTURES_CONFIG.connectionString,
containerName: BLOB_PROFILE_PICTURES_CONFIG.containerName,
}
case 'og-images':
return {
accountName: BLOB_OG_IMAGES_CONFIG.accountName || BLOB_CONFIG.accountName,
accountKey: BLOB_OG_IMAGES_CONFIG.accountKey || BLOB_CONFIG.accountKey,
connectionString: BLOB_OG_IMAGES_CONFIG.connectionString || BLOB_CONFIG.connectionString,
containerName: BLOB_OG_IMAGES_CONFIG.containerName || BLOB_CONFIG.containerName,
}
case 'workspace-logos':
return {
accountName: BLOB_WORKSPACE_LOGOS_CONFIG.accountName || BLOB_CONFIG.accountName,
accountKey: BLOB_WORKSPACE_LOGOS_CONFIG.accountKey || BLOB_CONFIG.accountKey,
connectionString:
BLOB_WORKSPACE_LOGOS_CONFIG.connectionString || BLOB_CONFIG.connectionString,
containerName: BLOB_WORKSPACE_LOGOS_CONFIG.containerName || BLOB_CONFIG.containerName,
}
default:
return {
accountName: BLOB_CONFIG.accountName,
accountKey: BLOB_CONFIG.accountKey,
connectionString: BLOB_CONFIG.connectionString,
containerName: BLOB_CONFIG.containerName,
}
}
}
/**
* Check if a specific storage context is configured
* Returns false if the context would fall back to general config but general isn't configured
*/
export function isStorageContextConfigured(context: StorageContext): boolean {
const config = getStorageConfig(context)
if (USE_BLOB_STORAGE) {
return !!(
config.containerName &&
(config.connectionString || (config.accountName && config.accountKey))
)
}
if (USE_S3_STORAGE) {
return !!(config.bucket && config.region)
}
return true
}
@@ -0,0 +1,95 @@
import { createLogger } from '@sim/logger'
import { processExecutionFiles } from '@/lib/execution/files'
import type { UserFile } from '@/executor/types'
const logger = createLogger('ChatFileManager')
export interface ChatFile {
data?: string // Legacy field - base64-encoded file data (data:mime;base64,...) or raw base64
dataUrl?: string // Preferred field - base64-encoded file data (data:mime;base64,...)
url?: string // Direct URL to existing file
name: string // Original filename
type: string // MIME type
}
export interface ChatExecutionContext {
workspaceId: string
workflowId: string
executionId: string
}
/**
* Process and upload chat files to temporary execution storage
*
* Handles two input formats:
* 1. Base64 dataUrl - File content encoded as data URL (uploaded from client)
* 2. Direct URL - Pass-through URL to existing file (already uploaded)
*
* Files are stored in the execution context with 5-10 minute expiry.
*
* @param files Array of chat file attachments
* @param executionContext Execution context for temporary storage
* @param requestId Unique request identifier for logging/tracing
* @param userId User ID for file metadata (optional)
* @returns Array of UserFile objects with upload results
*/
export async function processChatFiles(
files: ChatFile[],
executionContext: ChatExecutionContext,
requestId: string,
userId?: string
): Promise<UserFile[]> {
logger.info(
`Processing ${files.length} chat files for execution ${executionContext.executionId}`,
{
requestId,
executionContext,
}
)
const transformedFiles = files.map((file) => {
const inlineData = file.dataUrl || file.data
return {
type: inlineData ? ('file' as const) : ('url' as const),
data: inlineData || file.url || '',
name: file.name,
mime: file.type,
}
})
const userFiles = await processExecutionFiles(
transformedFiles,
executionContext,
requestId,
userId
)
logger.info(`Successfully processed ${userFiles.length} chat files`, {
requestId,
executionId: executionContext.executionId,
})
return userFiles
}
/**
* Upload a single chat file to temporary execution storage
*
* This is a convenience function for uploading individual files.
* For batch uploads, use processChatFiles() for better performance.
*
* @param file Chat file to upload
* @param executionContext Execution context for temporary storage
* @param requestId Unique request identifier
* @returns UserFile object with upload result
*/
async function uploadChatFile(
file: ChatFile,
executionContext: ChatExecutionContext,
requestId: string,
userId?: string
): Promise<UserFile> {
const [userFile] = await processChatFiles([file], executionContext, requestId, userId)
return userFile
}
@@ -0,0 +1 @@
export { processChatFiles } from './chat-file-manager'
@@ -0,0 +1,248 @@
import { createLogger } from '@sim/logger'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
deleteFile,
downloadFile,
generatePresignedDownloadUrl,
generatePresignedUploadUrl,
uploadFile,
} from '@/lib/uploads/core/storage-service'
import type { PresignedUrlResponse } from '@/lib/uploads/shared/types'
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
const logger = createLogger('CopilotFileManager')
const SUPPORTED_FILE_TYPES = [
// Images
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
// Documents
'application/pdf',
'text/plain',
'text/csv',
'text/markdown',
'text/html',
'application/json',
'application/xml',
'text/xml',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/x-pptxgenjs',
'text/x-docxjs',
'text/x-python-pdf',
'text/x-python-xlsx',
]
/**
* Check if a MIME type is supported for copilot attachments
*/
export function isSupportedFileType(mimeType: string): boolean {
return SUPPORTED_FILE_TYPES.includes(mimeType.toLowerCase())
}
interface CopilotFileAttachment {
key: string
filename: string
media_type: string
}
export interface GenerateCopilotUploadUrlOptions {
fileName: string
contentType: string
fileSize: number
userId: string
expirationSeconds?: number
}
export interface CopilotStoredFile {
id: string
key: string
context: 'copilot'
name: string
url: string
size: number
type: string
mimeType: string
}
/**
* Generate a presigned URL for copilot file upload
*
* Images and document files are allowed for copilot uploads.
* Requires authenticated user session.
*
* @param options Upload URL generation options
* @returns Presigned URL response with upload URL and file key
* @throws Error if file type is unsupported or user is not authenticated
*/
export async function generateCopilotUploadUrl(
options: GenerateCopilotUploadUrlOptions
): Promise<PresignedUrlResponse> {
const { fileName, contentType, fileSize, userId, expirationSeconds = 3600 } = options
if (!userId?.trim()) {
throw new Error('Authenticated user session is required for copilot uploads')
}
if (!isSupportedFileType(contentType) && !isImageFileType(contentType)) {
throw new Error(
'Unsupported file type. Allowed: images (JPEG, PNG, GIF, WebP), PDF, and text files (TXT, CSV, MD, HTML, JSON, XML).'
)
}
const presignedUrlResponse = await generatePresignedUploadUrl({
fileName,
contentType,
fileSize,
context: 'copilot',
userId,
expirationSeconds,
})
logger.info(`Generated copilot upload URL for: ${fileName}`, {
key: presignedUrlResponse.key,
userId,
})
return presignedUrlResponse
}
export async function uploadCopilotFile(options: {
buffer: Buffer
fileName: string
contentType: string
userId: string
}): Promise<CopilotStoredFile> {
const fileInfo = await uploadFile({
file: options.buffer,
fileName: options.fileName,
contentType: options.contentType,
context: 'copilot',
metadata: {
userId: options.userId,
originalName: options.fileName,
uploadedAt: new Date().toISOString(),
purpose: 'copilot-tool-output',
},
})
const url = `${getBaseUrl()}${fileInfo.path}`
logger.info(`Stored copilot tool output: ${options.fileName}`, {
key: fileInfo.key,
size: fileInfo.size,
userId: options.userId,
})
return {
id: fileInfo.key,
key: fileInfo.key,
context: 'copilot',
name: fileInfo.name,
url,
size: fileInfo.size,
type: fileInfo.type,
mimeType: fileInfo.type,
}
}
/**
* Download a copilot file from storage
*
* Uses the unified storage service with explicit copilot context.
* Handles S3, Azure Blob, and local storage automatically.
*
* @param key File storage key
* @returns File buffer
* @throws Error if file not found or download fails
*/
export async function downloadCopilotFile(key: string): Promise<Buffer> {
try {
const fileBuffer = await downloadFile({
key,
context: 'copilot',
})
logger.info(`Successfully downloaded copilot file: ${key}`, {
size: fileBuffer.length,
})
return fileBuffer
} catch (error) {
logger.error(`Failed to download copilot file: ${key}`, error)
throw error
}
}
/**
* Process copilot file attachments for chat messages
*
* Downloads files from storage and validates they are supported types.
* Skips unsupported files with a warning.
*
* @param attachments Array of file attachments
* @param requestId Request identifier for logging
* @returns Array of buffers for successfully downloaded files
*/
export async function processCopilotAttachments(
attachments: CopilotFileAttachment[],
requestId: string
): Promise<Array<{ buffer: Buffer; attachment: CopilotFileAttachment }>> {
const results: Array<{ buffer: Buffer; attachment: CopilotFileAttachment }> = []
for (const attachment of attachments) {
try {
if (!isSupportedFileType(attachment.media_type)) {
logger.warn(`[${requestId}] Unsupported file type: ${attachment.media_type}`)
continue
}
const buffer = await downloadCopilotFile(attachment.key)
results.push({ buffer, attachment })
} catch (error) {
logger.error(`[${requestId}] Failed to process file ${attachment.filename}:`, error)
}
}
logger.info(`Successfully processed ${results.length}/${attachments.length} attachments`, {
requestId,
})
return results
}
/**
* Generate a presigned download URL for a copilot file
*
* @param key File storage key
* @param expirationSeconds Time in seconds until URL expires (default: 1 hour)
* @returns Presigned download URL
*/
export async function generateCopilotDownloadUrl(
key: string,
expirationSeconds = 3600
): Promise<string> {
const downloadUrl = await generatePresignedDownloadUrl(key, 'copilot', expirationSeconds)
logger.info(`Generated copilot download URL for: ${key}`)
return downloadUrl
}
/**
* Delete a copilot file from storage
*
* @param key File storage key
*/
export async function deleteCopilotFile(key: string): Promise<void> {
await deleteFile({
key,
context: 'copilot',
})
logger.info(`Successfully deleted copilot file: ${key}`)
}
@@ -0,0 +1,6 @@
export type { CopilotStoredFile } from './copilot-file-manager'
export {
downloadCopilotFile,
generateCopilotUploadUrl,
uploadCopilotFile,
} from './copilot-file-manager'
@@ -0,0 +1,190 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import type { ExecutionContext } from '@/lib/uploads/contexts/execution/utils'
import { generateExecutionFileKey, generateFileId } from '@/lib/uploads/contexts/execution/utils'
import type { UserFile } from '@/executor/types'
const logger = createLogger('ExecutionFileStorage')
async function getStorageService() {
return import('@/lib/uploads/core/storage-service')
}
function isSerializedBuffer(value: unknown): value is { type: string; data: number[] } {
return (
!!value &&
typeof value === 'object' &&
(value as { type?: unknown }).type === 'Buffer' &&
Array.isArray((value as { data?: unknown }).data)
)
}
function toBuffer(data: unknown, fileName: string): Buffer {
if (data === undefined || data === null) {
throw new Error(`File '${fileName}' has no data`)
}
if (Buffer.isBuffer(data)) {
return data
}
if (isSerializedBuffer(data)) {
return Buffer.from(data.data)
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data)
}
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
}
if (Array.isArray(data)) {
return Buffer.from(data)
}
if (typeof data === 'string') {
const trimmed = data.trim()
if (trimmed.startsWith('data:')) {
const [, base64Data] = trimmed.split(',')
return Buffer.from(base64Data ?? '', 'base64')
}
return Buffer.from(trimmed, 'base64')
}
throw new Error(`File '${fileName}' has unsupported data format: ${typeof data}`)
}
/**
* Upload a file to execution-scoped storage
*/
export async function uploadExecutionFile(
context: ExecutionContext,
fileBuffer: Buffer,
fileName: string,
contentType: string,
userId?: string
): Promise<UserFile> {
logger.info(`Uploading execution file: ${fileName} for execution ${context.executionId}`)
logger.debug(`File upload context:`, {
workspaceId: context.workspaceId,
workflowId: context.workflowId,
executionId: context.executionId,
userId: userId || 'not provided',
fileName,
bufferSize: fileBuffer.length,
})
const storageKey = generateExecutionFileKey(context, fileName)
const fileId = generateFileId()
logger.info(`Generated storage key: "${storageKey}" for file: ${fileName}`)
const metadata: Record<string, string> = {
originalName: fileName,
uploadedAt: new Date().toISOString(),
purpose: 'execution',
workspaceId: context.workspaceId,
}
if (userId) {
metadata.userId = userId
}
try {
const StorageService = await getStorageService()
const fileInfo = await StorageService.uploadFile({
file: fileBuffer,
fileName: storageKey,
contentType,
context: 'execution',
preserveKey: true, // Don't add timestamp prefix
customKey: storageKey, // Use exact execution-scoped key
metadata, // Pass metadata for cloud storage and database tracking
})
const presignedUrl = await StorageService.generatePresignedDownloadUrl(
fileInfo.key,
'execution',
5 * 60
)
const userFile: UserFile = {
id: fileId,
name: fileName,
size: fileBuffer.length,
type: contentType,
url: presignedUrl,
key: fileInfo.key,
context: 'execution',
}
logger.info(`Successfully uploaded execution file: ${fileName} (${fileBuffer.length} bytes)`, {
key: fileInfo.key,
})
return userFile
} catch (error) {
logger.error(`Failed to upload execution file ${fileName}:`, error)
throw new Error(`Failed to upload file: ${getErrorMessage(error, 'Unknown error')}`)
}
}
/**
* Download a file from execution-scoped storage
*/
export async function downloadExecutionFile(
userFile: UserFile,
options: { maxBytes?: number } = {}
): Promise<Buffer> {
logger.info(`Downloading execution file: ${userFile.name}`)
try {
const StorageService = await getStorageService()
const fileBuffer = await StorageService.downloadFile({
key: userFile.key,
context: 'execution',
...(options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes }),
})
logger.info(
`Successfully downloaded execution file: ${userFile.name} (${fileBuffer.length} bytes)`
)
return fileBuffer
} catch (error) {
if (isPayloadSizeLimitError(error)) {
throw error
}
logger.error(`Failed to download execution file ${userFile.name}:`, error)
throw new Error(`Failed to download file: ${getErrorMessage(error, 'Unknown error')}`)
}
}
/**
* Convert raw file data (from tools/triggers) to UserFile
* Handles all common formats: Buffer, serialized Buffer, base64, data URLs
*/
export async function uploadFileFromRawData(
rawData: {
name?: string
filename?: string
data?: unknown
mimeType?: string
contentType?: string
size?: number
},
context: ExecutionContext,
userId?: string
): Promise<UserFile> {
if (isUserFileWithMetadata(rawData)) {
return rawData
}
const fileName = rawData.name || rawData.filename || 'file.bin'
const buffer = toBuffer(rawData.data, fileName)
const contentType = rawData.mimeType || rawData.contentType || 'application/octet-stream'
return uploadExecutionFile(context, buffer, fileName, contentType, userId)
}
@@ -0,0 +1,2 @@
export * from './execution-file-manager'
export * from './utils'
@@ -0,0 +1,60 @@
import { randomFloat } from '@sim/utils/random'
import { isUuid, sanitizeFileName } from '@/executor/constants'
import type { UserFile } from '@/executor/types'
/**
* Execution context for file operations
*/
export interface ExecutionContext {
workspaceId: string
workflowId: string
executionId: string
}
/**
* Generate execution-scoped storage key with explicit prefix
* Format: execution/workspace_id/workflow_id/execution_id/filename
*/
export function generateExecutionFileKey(context: ExecutionContext, fileName: string): string {
const { workspaceId, workflowId, executionId } = context
const safeFileName = sanitizeFileName(fileName)
return `execution/${workspaceId}/${workflowId}/${executionId}/${safeFileName}`
}
/**
* Generate unique file ID for execution files
*/
export function generateFileId(): string {
return `file_${Date.now()}_${randomFloat().toString(36).substring(2, 9)}`
}
/**
* Check if a key matches execution file pattern
* Execution files have keys in format: execution/workspaceId/workflowId/executionId/filename
*/
function matchesExecutionFilePattern(key: string): boolean {
if (!key || key.startsWith('/api/') || key.startsWith('http')) {
return false
}
const parts = key.split('/')
if (parts[0] === 'execution' && parts.length >= 5) {
const [, workspaceId, workflowId, executionId] = parts
return isUuid(workspaceId) && isUuid(workflowId) && isUuid(executionId)
}
return false
}
/**
* Check if a file is from execution storage based on its key pattern
* Execution files have keys in format: execution/workspaceId/workflowId/executionId/filename
*/
export function isExecutionFile(file: UserFile): boolean {
if (!file.key) {
return false
}
return matchesExecutionFilePattern(file.key)
}
@@ -0,0 +1,16 @@
import { randomBytes } from 'crypto'
import { sanitizeFileName } from '@/executor/constants'
/**
* Generate a canonical knowledge-base storage key.
*
* Direct/presigned uploads previously used the generic `${context}/...` key
* shape (`knowledge-base/...`). New KB uploads should use the same `kb/...`
* prefix as server-side uploads so key-derived context inference is consistent.
*/
export function generateKnowledgeBaseFileKey(fileName: string): string {
const timestamp = Date.now()
const random = randomBytes(8).toString('hex')
const safeFileName = sanitizeFileName(fileName)
return `kb/${timestamp}-${random}-${safeFileName}`
}
@@ -0,0 +1,237 @@
/**
* @vitest-environment node
*/
import {
inputValidationMock,
inputValidationMockFns,
permissionsMock,
permissionsMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockUploadWorkspaceFile } = vi.hoisted(() => ({
mockUploadWorkspaceFile: vi.fn(),
}))
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
uploadWorkspaceFile: mockUploadWorkspaceFile,
}))
import {
ExternalUrlValidationError,
fetchExternalUrlToWorkspace,
} from '@/lib/uploads/contexts/workspace/fetch-external-url'
function makeResponse(body: string, contentType = 'application/octet-stream'): Response {
return new Response(body, { status: 200, headers: { 'content-type': contentType } })
}
describe('fetchExternalUrlToWorkspace', () => {
beforeEach(() => {
vi.clearAllMocks()
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: '203.0.113.10',
})
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
mockUploadWorkspaceFile.mockImplementation(
async (workspaceId: string, _userId: string, _buffer: Buffer, fileName: string) => ({
id: `wf_${fileName}`,
name: fileName,
size: 0,
type: 'application/octet-stream',
url: `/api/files/serve/${workspaceId}/${fileName}`,
key: `${workspaceId}/${fileName}`,
context: 'workspace',
})
)
})
it('downloads each URL independently — never dedups by path filename', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP
.mockResolvedValueOnce(makeResponse('first bytes', 'image/png'))
.mockResolvedValueOnce(makeResponse('different second bytes', 'image/png'))
const first = await fetchExternalUrlToWorkspace({
url: 'https://files.slack.com/files-pri/T07-FAAA/download/image.png',
userId: 'user-1',
workspaceId: 'workspace-1',
})
const second = await fetchExternalUrlToWorkspace({
url: 'https://files.slack.com/files-pri/T07-FBBB/download/image.png',
userId: 'user-1',
workspaceId: 'workspace-1',
})
expect(first.filename).toBe('image.png')
expect(second.filename).toBe('image.png')
expect(first.buffer.toString()).toBe('first bytes')
expect(second.buffer.toString()).toBe('different second bytes')
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(2)
expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(2)
})
it('throws ExternalUrlValidationError when SSRF validation fails', async () => {
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
isValid: false,
error: 'Blocked private IP',
})
await expect(
fetchExternalUrlToWorkspace({
url: 'http://169.254.169.254/secret',
userId: 'user-1',
})
).rejects.toBeInstanceOf(ExternalUrlValidationError)
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
})
it('throws on non-2xx fetch responses', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
new Response('not found', { status: 404, statusText: 'Not Found' })
)
await expect(
fetchExternalUrlToWorkspace({
url: 'https://example.com/missing.txt',
userId: 'user-1',
})
).rejects.toThrow(/404/)
})
it('skips workspace save when saveToWorkspace is false', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
makeResponse('bytes', 'text/plain')
)
const result = await fetchExternalUrlToWorkspace({
url: 'https://example.com/file.txt',
userId: 'user-1',
workspaceId: 'workspace-1',
saveToWorkspace: false,
})
expect(result.savedWorkspaceFile).toBeUndefined()
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
expect(permissionsMockFns.mockGetUserEntityPermissions).not.toHaveBeenCalled()
})
it('skips workspace save when no workspaceId is provided', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
makeResponse('bytes', 'text/plain')
)
const result = await fetchExternalUrlToWorkspace({
url: 'https://example.com/file.txt',
userId: 'user-1',
})
expect(result.savedWorkspaceFile).toBeUndefined()
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
})
it('skips workspace save when user lacks write permission', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
makeResponse('bytes', 'text/plain')
)
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read')
const result = await fetchExternalUrlToWorkspace({
url: 'https://example.com/file.txt',
userId: 'user-1',
workspaceId: 'workspace-1',
})
expect(result.savedWorkspaceFile).toBeUndefined()
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
})
it('returns parsed bytes but skips save when user is not a workspace member', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
makeResponse('bytes', 'text/plain')
)
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null)
const result = await fetchExternalUrlToWorkspace({
url: 'https://example.com/file.txt',
userId: 'user-1',
workspaceId: 'workspace-1',
})
expect(result.buffer.toString()).toBe('bytes')
expect(result.savedWorkspaceFile).toBeUndefined()
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
})
it('returns the saved workspace file when permission allows save', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
makeResponse('bytes', 'text/plain')
)
const result = await fetchExternalUrlToWorkspace({
url: 'https://example.com/notes.txt',
userId: 'user-1',
workspaceId: 'workspace-1',
})
expect(mockUploadWorkspaceFile).toHaveBeenCalledWith(
'workspace-1',
'user-1',
expect.any(Buffer),
'notes.txt',
'text/plain'
)
expect(result.savedWorkspaceFile?.key).toBe('workspace-1/notes.txt')
})
it('swallows workspace save errors so parsing can still proceed', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
makeResponse('bytes', 'text/plain')
)
mockUploadWorkspaceFile.mockRejectedValueOnce(new Error('disk full'))
const result = await fetchExternalUrlToWorkspace({
url: 'https://example.com/file.txt',
userId: 'user-1',
workspaceId: 'workspace-1',
})
expect(result.buffer.toString()).toBe('bytes')
expect(result.savedWorkspaceFile).toBeUndefined()
})
it('forwards custom headers to the fetch', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
makeResponse('bytes', 'text/plain')
)
await fetchExternalUrlToWorkspace({
url: 'https://files.slack.com/files-pri/T07/download/report.txt',
userId: 'user-1',
headers: { Authorization: 'Bearer xoxb-test-token' },
})
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith(
'https://files.slack.com/files-pri/T07/download/report.txt',
'203.0.113.10',
expect.objectContaining({
headers: { Authorization: 'Bearer xoxb-test-token' },
})
)
})
it('uses content-type from response headers', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
makeResponse('pdf bytes', 'application/pdf')
)
const result = await fetchExternalUrlToWorkspace({
url: 'https://example.com/report.pdf',
userId: 'user-1',
})
expect(result.mimeType).toBe('application/pdf')
})
})
@@ -0,0 +1,155 @@
import type { Buffer } from 'buffer'
import path from 'path'
import { createLogger } from '@sim/logger'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import {
DEFAULT_MAX_ERROR_BODY_BYTES,
readResponseTextWithLimit,
readResponseToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import type { UserFile } from '@/executor/types'
const logger = createLogger('FetchExternalUrl')
const DEFAULT_TIMEOUT_MS = 30_000
const DEFAULT_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024
/**
* Thrown when the URL fails SSRF/DNS validation. Callers should map this to a
* user-facing 4xx-style response rather than a generic fetch failure.
*/
export class ExternalUrlValidationError extends Error {
constructor(message: string) {
super(message)
this.name = 'ExternalUrlValidationError'
}
}
export interface FetchExternalUrlOptions {
url: string
userId: string
/** When provided alongside `saveToWorkspace: true`, the downloaded bytes are persisted as a workspace file. */
workspaceId?: string
/** Defaults to true when a `workspaceId` is provided. Set false when the URL already points at our own storage. */
saveToWorkspace?: boolean
headers?: Record<string, string>
signal?: AbortSignal
maxDownloadBytes?: number
timeoutMs?: number
}
export interface FetchExternalUrlResult {
/**
* Filename derived from the URL path. NOT a content identity — distinct URLs
* frequently share the same path tail (e.g. every Slack clipboard paste is
* `image.png`). Never use this as a cache key.
*/
filename: string
buffer: Buffer
/** Content-Type from the response, or inferred from the filename extension. */
mimeType: string
/**
* Saved workspace file record. Undefined when the workspace save was skipped
* (no workspaceId, `saveToWorkspace: false`, missing write permission, or a
* save error — the last is logged, not thrown, so the parse path stays alive).
*/
savedWorkspaceFile?: UserFile
}
/**
* Fetch an external URL into memory and (optionally) save it as a fresh workspace file.
*
* URL fetches are NEVER deduplicated by filename. Two URLs whose paths end in
* `image.png` are two different fetches that produce two different workspace
* files; `uploadWorkspaceFile` allocates a unique on-disk name (`image.png`,
* `image (1).png`, ...) on the save side. Keying a cache by path tail would
* silently return stale bytes — that was the original bug this helper exists
* to make unrepresentable.
*/
export async function fetchExternalUrlToWorkspace(
options: FetchExternalUrlOptions
): Promise<FetchExternalUrlResult> {
const {
url,
userId,
workspaceId,
saveToWorkspace = Boolean(workspaceId),
headers,
signal,
maxDownloadBytes = DEFAULT_MAX_DOWNLOAD_BYTES,
timeoutMs = DEFAULT_TIMEOUT_MS,
} = options
const urlValidation = await validateUrlWithDNS(url, 'fileUrl')
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
throw new ExternalUrlValidationError(urlValidation.error || 'Invalid external URL')
}
const filename = new URL(url).pathname.split('/').pop() || 'download'
const extension = path.extname(filename).toLowerCase().substring(1)
const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP, {
timeout: timeoutMs,
maxResponseBytes: maxDownloadBytes,
signal,
...(headers && Object.keys(headers).length > 0 && { headers }),
})
if (!response.ok) {
await readResponseTextWithLimit(response, {
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
label: 'external url error body',
signal,
}).catch(() => '')
throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`)
}
const buffer = await readResponseToBufferWithLimit(response, {
maxBytes: maxDownloadBytes,
label: 'external url download',
signal,
})
const mimeType = response.headers.get('content-type') || getMimeTypeFromExtension(extension)
let savedWorkspaceFile: UserFile | undefined
if (workspaceId && saveToWorkspace) {
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission === 'admin' || permission === 'write') {
try {
savedWorkspaceFile = await uploadWorkspaceFile(
workspaceId,
userId,
buffer,
filename,
mimeType
)
} catch (saveError) {
logger.warn('Failed to save fetched URL to workspace storage', {
workspaceId,
filename,
saveError,
})
}
} else if (permission === null) {
logger.warn('Skipping workspace save: user is not a workspace member', {
userId,
workspaceId,
})
} else {
logger.warn('Skipping workspace save: user lacks write permission', {
userId,
workspaceId,
permission,
})
}
}
return { filename, buffer, mimeType, savedWorkspaceFile }
}
@@ -0,0 +1,3 @@
export * from './fetch-external-url'
export * from './workspace-file-folder-manager'
export * from './workspace-file-manager'
@@ -0,0 +1,161 @@
/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
import { CHAT_DISPLAY_NAME_INDEX, suffixedName, trackChatUpload } from './workspace-file-manager'
const CHAT_ID = '11111111-1111-1111-1111-111111111111'
const WORKSPACE_ID = 'ws_1'
const USER_ID = 'user_1'
const S3_KEY = 'mothership/abc/123-image.png'
describe('suffixedName', () => {
it('returns the original name for n <= 1', () => {
expect(suffixedName('image.png', 1)).toBe('image.png')
expect(suffixedName('image.png', 0)).toBe('image.png')
})
it('inserts " (n)" before the extension', () => {
expect(suffixedName('image.png', 2)).toBe('image (2).png')
expect(suffixedName('image.png', 3)).toBe('image (3).png')
expect(suffixedName('My File.tar.gz', 2)).toBe('My File.tar (2).gz')
})
it('appends " (n)" for extensionless names', () => {
expect(suffixedName('README', 2)).toBe('README (2)')
expect(suffixedName('Makefile', 5)).toBe('Makefile (5)')
})
it('treats dotfiles as extensionless (leading dot only)', () => {
expect(suffixedName('.env', 2)).toBe('.env (2)')
expect(suffixedName('.gitignore', 3)).toBe('.gitignore (3)')
})
it('treats trailing-dot names as extensionless', () => {
expect(suffixedName('weird.', 2)).toBe('weird. (2)')
})
})
describe('trackChatUpload', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('flips an existing workspace-scope row to mothership and returns the displayName', async () => {
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'wf_existing' }])
const result = await trackChatUpload(
WORKSPACE_ID,
USER_ID,
CHAT_ID,
S3_KEY,
'image.png',
'image/png',
1024
)
expect(result).toEqual({ displayName: 'image.png' })
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({
chatId: CHAT_ID,
context: 'mothership',
displayName: 'image.png',
})
)
})
it('inserts a new row when no existing key matches', async () => {
// UPDATE returns no rows — falls through to INSERT.
dbChainMockFns.returning.mockResolvedValueOnce([])
const result = await trackChatUpload(
WORKSPACE_ID,
USER_ID,
CHAT_ID,
S3_KEY,
'image.png',
'image/png',
1024
)
expect(result).toEqual({ displayName: 'image.png' })
expect(dbChainMockFns.values).toHaveBeenCalledWith(
expect.objectContaining({
key: S3_KEY,
chatId: CHAT_ID,
context: 'mothership',
originalName: 'image.png',
displayName: 'image.png',
})
)
})
it('retries with a suffixed displayName on collision against the chat displayName index', async () => {
// 23505 from the partial unique index on (chat_id, display_name) — the case we retry.
const displayNameCollision = Object.assign(new Error('duplicate key'), {
code: '23505',
constraint_name: CHAT_DISPLAY_NAME_INDEX,
})
// Attempt 1: UPDATE finds no row (returning -> []), then INSERT throws displayName 23505.
dbChainMockFns.returning.mockResolvedValueOnce([])
dbChainMockFns.values.mockRejectedValueOnce(displayNameCollision)
// Attempt 2: UPDATE finds no row, INSERT succeeds.
dbChainMockFns.returning.mockResolvedValueOnce([])
dbChainMockFns.values.mockResolvedValueOnce(undefined)
const result = await trackChatUpload(
WORKSPACE_ID,
USER_ID,
CHAT_ID,
S3_KEY,
'image.png',
'image/png',
1024
)
expect(result).toEqual({ displayName: 'image (2).png' })
const lastValuesCall =
dbChainMockFns.values.mock.calls[dbChainMockFns.values.mock.calls.length - 1]
expect(lastValuesCall[0]).toMatchObject({
displayName: 'image (2).png',
originalName: 'image.png',
})
})
it('does NOT retry on a 23505 from the active-key index (concurrent same-s3Key insert)', async () => {
// A racing concurrent trackChatUpload for the same s3Key hit INSERT first. Our INSERT
// 23505s on workspace_files_key_active_unique. Retrying with a suffixed displayName
// would let the next iteration UPDATE the racer's row and silently rename the path
// it already returned to its caller — so we throw instead.
const keyCollision = Object.assign(new Error('duplicate key'), {
code: '23505',
constraint_name: 'workspace_files_key_active_unique',
})
dbChainMockFns.returning.mockResolvedValueOnce([])
dbChainMockFns.values.mockRejectedValueOnce(keyCollision)
await expect(
trackChatUpload(WORKSPACE_ID, USER_ID, CHAT_ID, S3_KEY, 'image.png', 'image/png', 1024)
).rejects.toThrow('duplicate key')
expect(dbChainMockFns.values).toHaveBeenCalledTimes(1)
})
it('rethrows non-unique-violation errors immediately', async () => {
dbChainMockFns.returning.mockRejectedValueOnce(new Error('connection lost'))
await expect(
trackChatUpload(WORKSPACE_ID, USER_ID, CHAT_ID, S3_KEY, 'image.png', 'image/png', 1024)
).rejects.toThrow('connection lost')
})
})
@@ -0,0 +1,33 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
buildWorkspaceFileFolderPathMap,
normalizeWorkspaceFileItemName,
} from './workspace-file-folder-manager'
describe('workspace file folder paths', () => {
it('builds nested paths from parent relationships', () => {
const paths = buildWorkspaceFileFolderPathMap([
{ id: 'reports', name: 'Reports', parentId: null },
{ id: 'quarterly', name: 'Quarterly', parentId: 'reports' },
{ id: 'archive', name: 'Archive', parentId: null },
])
expect(paths.get('reports')).toBe('Reports')
expect(paths.get('quarterly')).toBe('Reports/Quarterly')
expect(paths.get('archive')).toBe('Archive')
})
it('rejects names that would create ambiguous paths', () => {
expect(normalizeWorkspaceFileItemName('Reports', 'Folder')).toBe('Reports')
expect(() => normalizeWorkspaceFileItemName('A/B', 'Folder')).toThrow(
'Folder name cannot contain path separators or dot segments'
)
expect(() => normalizeWorkspaceFileItemName('..', 'File')).toThrow(
'File name cannot contain path separators or dot segments'
)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
findWorkspaceFileRecord,
normalizeWorkspaceFileReference,
type WorkspaceFileRecord,
} from './workspace-file-manager'
const FILE_ID = 'ec28e5d5-898a-48f0-aa6f-2fd7427c9563'
function makeFileRecord(): WorkspaceFileRecord {
return {
id: FILE_ID,
workspaceId: 'ws_123',
name: 'the_last_cartographer_of_vael.md',
key: 'workspace/ws_123/mock-key',
path: '/api/files/serve/mock-key?context=workspace',
size: 128,
type: 'text/markdown',
uploadedBy: 'user_123',
folderId: null,
folderPath: null,
uploadedAt: new Date('2026-04-13T00:00:00.000Z'),
updatedAt: new Date('2026-04-13T00:00:00.000Z'),
}
}
describe('workspace file reference normalization', () => {
it('normalizes canonical VFS paths to their sanitized display path', () => {
expect(normalizeWorkspaceFileReference('files/Reports/q1.csv/content')).toBe('Reports/q1.csv')
expect(normalizeWorkspaceFileReference('files/Reports/q1.csv/meta.json')).toBe('Reports/q1.csv')
expect(normalizeWorkspaceFileReference('recently-deleted/files/data.csv/content')).toBe(
'data.csv'
)
})
it('still resolves a raw file id passed directly', () => {
const files = [makeFileRecord()]
expect(findWorkspaceFileRecord(files, FILE_ID)).toMatchObject({
id: FILE_ID,
name: 'the_last_cartographer_of_vael.md',
})
})
it('does not resolve id-based VFS paths', () => {
const files = [makeFileRecord()]
expect(findWorkspaceFileRecord(files, `files/by-id/${FILE_ID}/content`)).toBeNull()
})
it('resolves duplicate names by folder-aware VFS path', () => {
const reportsFile: WorkspaceFileRecord = {
...makeFileRecord(),
id: 'file-reports',
name: 'q1.csv',
folderId: 'folder-reports',
folderPath: 'Reports',
}
const archiveFile: WorkspaceFileRecord = {
...makeFileRecord(),
id: 'file-archive',
name: 'q1.csv',
folderId: 'folder-archive',
folderPath: 'Archive',
}
expect(
findWorkspaceFileRecord([reportsFile, archiveFile], 'files/Reports/q1.csv/content')
).toBe(reportsFile)
expect(findWorkspaceFileRecord([reportsFile, archiveFile], 'files/Archive/q1.csv')).toBe(
archiveFile
)
})
})
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
import { existsSync } from 'fs'
import { mkdir } from 'fs/promises'
import path, { join } from 'path'
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
import {
getStorageProvider,
S3_CONFIG,
USE_BLOB_STORAGE,
USE_S3_STORAGE,
} from '@/lib/uploads/config'
const logger = createLogger('UploadsSetup')
// turbopackIgnore: an unscoped process.cwd() makes node-file-tracing sweep the whole
// project (including next.config.ts) into every route graph that reaches this module.
// Two routes doing so emit the swept config into same-named server chunks — when their
// contents diverge, the build dies with "Two or more assets … same output path".
const PROJECT_ROOT = path.resolve(/*turbopackIgnore: true*/ process.cwd())
export const UPLOAD_DIR_SERVER = join(/*turbopackIgnore: true*/ PROJECT_ROOT, 'uploads')
/**
* Server-only function to ensure uploads directory exists
*/
async function ensureUploadsDirectory() {
if (USE_S3_STORAGE) {
logger.info('Using S3 storage, skipping local uploads directory creation')
return true
}
if (USE_BLOB_STORAGE) {
logger.info('Using Azure Blob storage, skipping local uploads directory creation')
return true
}
try {
if (!existsSync(UPLOAD_DIR_SERVER)) {
await mkdir(UPLOAD_DIR_SERVER, { recursive: true })
} else {
logger.info(`Uploads directory already exists at ${UPLOAD_DIR_SERVER}`)
}
return true
} catch (error) {
logger.error('Failed to create uploads directory:', error)
return false
}
}
// Immediately invoke on server startup
if (typeof process !== 'undefined') {
const storageProvider = getStorageProvider()
// Log storage mode
logger.info(`Storage provider: ${storageProvider}`)
if (USE_BLOB_STORAGE) {
// Verify Azure Blob credentials
if (!env.AZURE_STORAGE_CONTAINER_NAME) {
logger.warn('Azure Blob storage is enabled but AZURE_STORAGE_CONTAINER_NAME is not set')
} else if (!env.AZURE_ACCOUNT_NAME && !env.AZURE_CONNECTION_STRING) {
logger.warn(
'Azure Blob storage is enabled but neither AZURE_ACCOUNT_NAME nor AZURE_CONNECTION_STRING is set'
)
logger.warn(
'Set AZURE_ACCOUNT_NAME + AZURE_ACCOUNT_KEY or AZURE_CONNECTION_STRING for Azure Blob storage'
)
} else if (env.AZURE_ACCOUNT_NAME && !env.AZURE_ACCOUNT_KEY && !env.AZURE_CONNECTION_STRING) {
logger.warn(
'AZURE_ACCOUNT_NAME is set but AZURE_ACCOUNT_KEY is missing and no AZURE_CONNECTION_STRING provided'
)
logger.warn('Set AZURE_ACCOUNT_KEY or use AZURE_CONNECTION_STRING for authentication')
} else {
logger.info('Azure Blob storage credentials found in environment variables')
if (env.AZURE_CONNECTION_STRING) {
logger.info('Using Azure connection string for authentication')
} else {
logger.info('Using Azure account name and key for authentication')
}
}
} else if (USE_S3_STORAGE) {
// Verify AWS credentials
if (!env.S3_BUCKET_NAME || !env.AWS_REGION) {
logger.warn('S3 storage configuration is incomplete')
logger.warn('Set S3_BUCKET_NAME and AWS_REGION for S3 storage')
} else if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
logger.warn('AWS credentials are not set in environment variables')
logger.warn('Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY for S3 storage')
} else {
logger.info('AWS S3 credentials found in environment variables')
}
if (env.S3_ENDPOINT) {
logger.info(
`Using S3-compatible endpoint: ${env.S3_ENDPOINT} (path-style: ${S3_CONFIG.forcePathStyle})`
)
}
} else {
// Local storage mode
logger.info('Using local file storage')
// Only initialize local uploads directory when using local storage
ensureUploadsDirectory().then((success) => {
if (success) {
logger.info('Local uploads directory initialized')
} else {
logger.error('Failed to initialize local uploads directory')
}
})
}
// Log additional configuration details
if (USE_BLOB_STORAGE && env.AZURE_STORAGE_KB_CONTAINER_NAME) {
logger.info(`Azure Blob knowledge base container: ${env.AZURE_STORAGE_KB_CONTAINER_NAME}`)
}
if (USE_BLOB_STORAGE && env.AZURE_STORAGE_COPILOT_CONTAINER_NAME) {
logger.info(`Azure Blob copilot container: ${env.AZURE_STORAGE_COPILOT_CONTAINER_NAME}`)
}
if (USE_S3_STORAGE && env.S3_KB_BUCKET_NAME) {
logger.info(`S3 knowledge base bucket: ${env.S3_KB_BUCKET_NAME}`)
}
if (USE_S3_STORAGE && env.S3_COPILOT_BUCKET_NAME) {
logger.info(`S3 copilot bucket: ${env.S3_COPILOT_BUCKET_NAME}`)
}
}
@@ -0,0 +1,97 @@
import { USE_BLOB_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/config'
import type { StorageConfig } from '@/lib/uploads/shared/types'
export type { StorageConfig } from '@/lib/uploads/shared/types'
/**
* Get the current storage provider name
*/
export function getStorageProvider(): 'blob' | 's3' | 'local' {
if (USE_BLOB_STORAGE) return 'blob'
if (USE_S3_STORAGE) return 's3'
return 'local'
}
/**
* Get the serve path prefix (unified across all storage providers)
*/
export function getServePathPrefix(): string {
return '/api/files/serve/'
}
/**
* Get file metadata from storage provider
* @param key File key/name
* @param customConfig Optional custom storage configuration
* @returns File metadata object with userId, workspaceId, originalName, uploadedAt, etc.
*/
export async function getFileMetadata(
key: string,
customConfig?: StorageConfig
): Promise<Record<string, string>> {
const { getFileMetadataByKey } = await import('../server/metadata')
const metadataRecord = await getFileMetadataByKey(key)
if (metadataRecord) {
return {
userId: metadataRecord.userId,
workspaceId: metadataRecord.workspaceId || '',
originalName: metadataRecord.originalName,
uploadedAt: metadataRecord.uploadedAt.toISOString(),
purpose: metadataRecord.context,
}
}
if (USE_BLOB_STORAGE) {
const { getBlobServiceClient } = await import('@/lib/uploads/providers/blob/client')
const { BLOB_CONFIG } = await import('@/lib/uploads/config')
let blobServiceClient = await getBlobServiceClient()
let containerName = BLOB_CONFIG.containerName
if (customConfig) {
const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob')
if (customConfig.connectionString) {
blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString)
} else if (customConfig.accountName && customConfig.accountKey) {
const credential = new StorageSharedKeyCredential(
customConfig.accountName,
customConfig.accountKey
)
blobServiceClient = new BlobServiceClient(
`https://${customConfig.accountName}.blob.core.windows.net`,
credential
)
}
containerName = customConfig.containerName || containerName
}
const containerClient = blobServiceClient.getContainerClient(containerName)
const blockBlobClient = containerClient.getBlockBlobClient(key)
const properties = await blockBlobClient.getProperties()
return properties.metadata || {}
}
if (USE_S3_STORAGE) {
const { getS3Client } = await import('@/lib/uploads/providers/s3/client')
const { HeadObjectCommand } = await import('@aws-sdk/client-s3')
const { S3_CONFIG } = await import('@/lib/uploads/config')
const s3Client = getS3Client()
const bucket = customConfig?.bucket || S3_CONFIG.bucket
if (!bucket) {
throw new Error('S3 bucket not configured')
}
const command = new HeadObjectCommand({
Bucket: bucket,
Key: key,
})
const response = await s3Client.send(command)
return response.Metadata || {}
}
return {}
}
@@ -0,0 +1,82 @@
/**
* Regression tests: Azure Blob storage must be fully usable with ONLY
* AZURE_CONNECTION_STRING set (no AZURE_ACCOUNT_NAME/AZURE_ACCOUNT_KEY) — this
* is the connection-string auth mode documented as a standalone alternative
* across .env.example, helm/sim/values.yaml, and env.ts.
*
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const CONNECTION_STRING =
'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;'
const { mockHeadBlobObject, mockGetBlobServiceClient, mockGenerateBlobSASQueryParameters } =
vi.hoisted(() => ({
mockHeadBlobObject: vi.fn(),
mockGetBlobServiceClient: vi.fn(),
mockGenerateBlobSASQueryParameters: vi.fn(() => ({ toString: () => 'sig=fake' })),
}))
vi.mock('@/lib/uploads/config', () => ({
USE_S3_STORAGE: false,
USE_BLOB_STORAGE: true,
// Connection-string-only: accountName/accountKey intentionally absent.
getStorageConfig: () => ({
containerName: 'workspace-files',
accountName: undefined,
accountKey: undefined,
connectionString: CONNECTION_STRING,
}),
}))
vi.mock('@/lib/uploads/providers/blob/client', () => ({
headBlobObject: mockHeadBlobObject,
getBlobServiceClient: mockGetBlobServiceClient,
parseConnectionString: (connectionString: string) => {
const accountName = connectionString.match(/AccountName=([^;]+)/)?.[1]
const accountKey = connectionString.match(/AccountKey=([^;]+)/)?.[1]
if (!accountName || !accountKey) throw new Error('cannot parse')
return { accountName, accountKey }
},
}))
vi.mock('@azure/storage-blob', () => ({
StorageSharedKeyCredential: vi.fn(),
BlobSASPermissions: { parse: vi.fn(() => 'w') },
generateBlobSASQueryParameters: mockGenerateBlobSASQueryParameters,
}))
import { generatePresignedUploadUrl, headObject } from '@/lib/uploads/core/storage-service'
describe('Azure Blob storage — connection-string-only auth', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHeadBlobObject.mockResolvedValue({ size: 42, contentType: 'text/plain' })
mockGetBlobServiceClient.mockResolvedValue({
getContainerClient: () => ({
getBlockBlobClient: () => ({ url: 'https://devstoreaccount1.blob.core.windows.net/c/k' }),
}),
})
})
it('headObject does not throw when only connectionString is configured', async () => {
await expect(headObject('some-key', 'workspace')).resolves.toEqual({
size: 42,
contentType: 'text/plain',
})
expect(mockHeadBlobObject).toHaveBeenCalled()
})
it('generatePresignedUploadUrl derives SAS credentials from connectionString when accountName/accountKey are absent', async () => {
const result = await generatePresignedUploadUrl({
fileName: 'report.csv',
contentType: 'text/csv',
context: 'workspace',
fileSize: 100,
})
expect(mockGenerateBlobSASQueryParameters).toHaveBeenCalled()
expect(result.url).toContain('sig=fake')
})
})
@@ -0,0 +1,102 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockInitiate, mockUploadPart, mockComplete, mockAbort, mockUploadToS3, partBodies } =
vi.hoisted(() => ({
mockInitiate: vi.fn(),
mockUploadPart: vi.fn(),
mockComplete: vi.fn(),
mockAbort: vi.fn(),
mockUploadToS3: vi.fn(),
partBodies: [] as Buffer[],
}))
vi.mock('@/lib/uploads/config', () => ({
USE_S3_STORAGE: true,
USE_BLOB_STORAGE: false,
getStorageConfig: () => ({ bucket: 'b', region: 'r' }),
}))
vi.mock('@/lib/uploads/providers/s3/client', () => ({
initiateS3MultipartUpload: mockInitiate,
uploadS3Part: mockUploadPart,
completeS3MultipartUpload: mockComplete,
abortS3MultipartUpload: mockAbort,
uploadToS3: mockUploadToS3,
}))
import { createMultipartUpload } from '@/lib/uploads/core/storage-service'
const PART_SIZE = 8 * 1024 * 1024
describe('createMultipartUpload', () => {
beforeEach(() => {
vi.clearAllMocks()
partBodies.length = 0
mockInitiate.mockResolvedValue({ uploadId: 'up1', key: 'k' })
mockUploadPart.mockImplementation((_key, _uploadId, partNumber: number, body: Buffer) => {
partBodies.push(body)
return Promise.resolve({ PartNumber: partNumber, ETag: `etag-${partNumber}` })
})
mockComplete.mockResolvedValue({ location: 'l', path: 'p', key: 'k' })
mockAbort.mockResolvedValue(undefined)
mockUploadToS3.mockResolvedValue({ key: 'k', path: 'p', name: 'k', size: 0, type: 'text/csv' })
})
it('takes the single-shot PutObject path for a payload smaller than one part', async () => {
const handle = await createMultipartUpload({
key: 'k',
context: 'execution',
contentType: 'text/csv',
})
await handle.write('hello')
const result = await handle.complete()
expect(mockInitiate).not.toHaveBeenCalled()
expect(mockUploadPart).not.toHaveBeenCalled()
expect(mockUploadToS3).toHaveBeenCalledTimes(1)
expect((mockUploadToS3.mock.calls[0][0] as Buffer).toString('utf8')).toBe('hello')
expect(result).toEqual({ key: 'k', size: 5 })
})
it('splits into parts and reassembles byte-for-byte over one part boundary', async () => {
const a = Buffer.alloc(5 * 1024 * 1024, 1)
const b = Buffer.alloc(5 * 1024 * 1024, 2)
const handle = await createMultipartUpload({
key: 'k',
context: 'execution',
contentType: 'text/csv',
})
await handle.write(a)
await handle.write(b)
const result = await handle.complete()
expect(mockInitiate).toHaveBeenCalledTimes(1)
// 10MB → one full 8MB part + a 2MB remainder on complete.
expect(mockUploadPart).toHaveBeenCalledTimes(2)
expect(partBodies[0].length).toBe(PART_SIZE)
const reassembled = Buffer.concat(partBodies)
expect(reassembled.length).toBe(10 * 1024 * 1024)
expect(reassembled.equals(Buffer.concat([a, b]))).toBe(true)
expect(mockComplete).toHaveBeenCalledTimes(1)
expect(result.size).toBe(10 * 1024 * 1024)
expect(mockUploadToS3).not.toHaveBeenCalled()
})
it('aborts the multipart upload and leaves no object', async () => {
const handle = await createMultipartUpload({
key: 'k',
context: 'execution',
contentType: 'text/csv',
})
await handle.write(Buffer.alloc(9 * 1024 * 1024, 7)) // crosses one part → multipart started
await handle.abort()
expect(mockInitiate).toHaveBeenCalledTimes(1)
expect(mockAbort).toHaveBeenCalledTimes(1)
expect(mockComplete).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,780 @@
import type { Readable } from 'node:stream'
import { randomBytes } from 'crypto'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits'
import { getStorageConfig, USE_BLOB_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/config'
import type { AzureMultipartPart, BlobConfig } from '@/lib/uploads/providers/blob/types'
import type { S3Config, S3MultipartPart } from '@/lib/uploads/providers/s3/types'
import type {
DeleteFileOptions,
DownloadFileOptions,
FileInfo,
GeneratePresignedUrlOptions,
PresignedUrlResponse,
StorageConfig,
StorageContext,
UploadFileOptions,
} from '@/lib/uploads/shared/types'
import {
sanitizeFileKey,
sanitizeFilenameForMetadata,
sanitizeStorageMetadata,
} from '@/lib/uploads/utils/file-utils'
const logger = createLogger('StorageService')
/**
* Create a Blob config from StorageConfig
* @throws Error if required properties are missing
*/
function createBlobConfig(config: StorageConfig): BlobConfig {
if (!config.containerName) {
throw new Error('Blob configuration missing required property: containerName')
}
if (!config.connectionString && !(config.accountName && config.accountKey)) {
throw new Error(
'Blob configuration missing authentication: either connectionString or both accountName and accountKey must be provided'
)
}
return {
containerName: config.containerName,
accountName: config.accountName,
accountKey: config.accountKey,
connectionString: config.connectionString,
}
}
/**
* Create an S3 config from StorageConfig
* @throws Error if required properties are missing
*/
function createS3Config(config: StorageConfig): S3Config {
if (!config.bucket || !config.region) {
throw new Error('S3 configuration missing required properties: bucket and region')
}
return {
bucket: config.bucket,
region: config.region,
}
}
/**
* Insert file metadata into the database
*/
async function insertFileMetadataHelper(
key: string,
metadata: Record<string, string>,
context: StorageContext,
fileName: string,
contentType: string,
fileSize: number
): Promise<void> {
const { insertFileMetadata } = await import('../server/metadata')
await insertFileMetadata({
key,
userId: metadata.userId,
workspaceId: metadata.workspaceId || null,
folderId: metadata.folderId || null,
context,
originalName: metadata.originalName || fileName,
contentType,
size: fileSize,
})
}
/**
* Upload a file to the configured storage provider with context-aware configuration
*/
export async function uploadFile(options: UploadFileOptions): Promise<FileInfo> {
const { file, fileName, contentType, context, preserveKey, customKey, metadata } = options
logger.info(`Uploading file to ${context} storage: ${fileName}`)
const config = getStorageConfig(context)
const keyToUse = customKey || fileName
if (USE_BLOB_STORAGE) {
const { uploadToBlob } = await import('@/lib/uploads/providers/blob/client')
const uploadResult = await uploadToBlob(
file,
keyToUse,
contentType,
createBlobConfig(config),
file.length,
preserveKey,
metadata
)
if (metadata) {
await insertFileMetadataHelper(
uploadResult.key,
metadata,
context,
fileName,
contentType,
file.length
)
}
return uploadResult
}
if (USE_S3_STORAGE) {
const { uploadToS3 } = await import('@/lib/uploads/providers/s3/client')
const uploadResult = await uploadToS3(
file,
keyToUse,
contentType,
createS3Config(config),
file.length,
preserveKey,
metadata
)
if (metadata) {
await insertFileMetadataHelper(
uploadResult.key,
metadata,
context,
fileName,
contentType,
file.length
)
}
return uploadResult
}
const { writeFile, mkdir } = await import('fs/promises')
const { join, dirname } = await import('path')
const { UPLOAD_DIR_SERVER } = await import('./setup.server')
const storageKey = keyToUse
const safeKey = sanitizeFileKey(keyToUse) // Validates and preserves path structure
const filesystemPath = join(UPLOAD_DIR_SERVER, safeKey)
await mkdir(dirname(filesystemPath), { recursive: true })
await writeFile(filesystemPath, file)
if (metadata) {
await insertFileMetadataHelper(
storageKey,
metadata,
context,
fileName,
contentType,
file.length
)
}
return {
path: `/api/files/serve/${storageKey}`,
key: storageKey,
name: fileName,
size: file.length,
type: contentType,
}
}
/** Part size for streaming multipart uploads. ≥ S3's 5MB minimum (all but the last part). */
const MULTIPART_PART_SIZE = 8 * 1024 * 1024
/** Max parts uploading concurrently — caps in-flight memory at ~`this × PART_SIZE`. */
const MULTIPART_MAX_INFLIGHT = 4
/**
* Streaming upload sink. The caller `write`s chunks (CSV rows, etc.) and `complete`s;
* the implementation buffers into ≥5MB parts and uploads them with bounded concurrency,
* so peak memory stays ~`MULTIPART_MAX_INFLIGHT × MULTIPART_PART_SIZE` regardless of total
* size. A payload that never crosses one part takes a plain single-shot PutObject.
*/
export interface MultipartUploadHandle {
write(chunk: Buffer | string): Promise<void>
complete(): Promise<{ key: string; size: number }>
abort(): Promise<void>
}
interface MultipartBackend {
uploadPart(partNumber: number, body: Buffer): Promise<void>
finish(): Promise<void>
abort(): Promise<void>
}
async function createS3Backend(
key: string,
config: S3Config,
contentType: string,
purpose: string
): Promise<MultipartBackend> {
const {
initiateS3MultipartUpload,
uploadS3Part,
completeS3MultipartUpload,
abortS3MultipartUpload,
} = await import('@/lib/uploads/providers/s3/client')
const { uploadId } = await initiateS3MultipartUpload({
fileName: key,
contentType,
fileSize: 0,
customConfig: config,
customKey: key,
purpose,
})
const parts: S3MultipartPart[] = []
return {
async uploadPart(partNumber, body) {
parts.push(await uploadS3Part(key, uploadId, partNumber, body, config))
},
finish: () => completeS3MultipartUpload(key, uploadId, parts, config).then(() => undefined),
abort: () => abortS3MultipartUpload(key, uploadId, config),
}
}
async function createBlobBackend(
key: string,
config: BlobConfig,
contentType: string
): Promise<MultipartBackend> {
const { stageBlobPart, commitBlobBlockList, abortMultipartUpload } = await import(
'@/lib/uploads/providers/blob/client'
)
const parts: AzureMultipartPart[] = []
return {
async uploadPart(partNumber, body) {
parts.push(await stageBlobPart(key, partNumber, body, config))
},
finish: () => commitBlobBlockList(key, parts, contentType, config),
abort: () => abortMultipartUpload(key, config),
}
}
/**
* Open a streaming multipart upload to the configured provider. On the local
* filesystem provider (and for any payload smaller than one part) the bytes are
* buffered and written via a single {@link uploadFile} on `complete`.
*/
export async function createMultipartUpload(options: {
key: string
context: StorageContext
contentType: string
}): Promise<MultipartUploadHandle> {
const { key, context, contentType } = options
const config = getStorageConfig(context)
const cloud = hasCloudStorage()
let backend: MultipartBackend | null = null
// Accumulate writes as references, not a growing buffer — concatenating only when a part fills
// (or on complete) keeps total copying ~O(bytes) instead of O(bytes × writes).
let pendingChunks: Buffer[] = []
let pendingBytes = 0
let totalBytes = 0
let partNumber = 0
let aborted = false
let firstError: unknown
const inflight = new Set<Promise<void>>()
/** Merge the accumulated chunks into one ArrayBuffer-backed buffer (which `uploadFile` expects). */
const drainPending = (): Buffer<ArrayBuffer> => Buffer.concat(pendingChunks, pendingBytes)
const ensureBackend = async (): Promise<MultipartBackend> => {
if (!backend) {
backend = USE_BLOB_STORAGE
? await createBlobBackend(key, createBlobConfig(config), contentType)
: await createS3Backend(key, createS3Config(config), contentType, context)
}
return backend
}
const dispatchPart = async (body: Buffer): Promise<void> => {
// Bound concurrency: wait for a free slot before starting another part.
while (inflight.size >= MULTIPART_MAX_INFLIGHT) await Promise.race(inflight)
if (firstError) throw firstError
const be = await ensureBackend()
const partNo = ++partNumber
const p = be
.uploadPart(partNo, body)
.catch((err) => {
firstError ??= err
})
.finally(() => {
inflight.delete(p)
})
inflight.add(p)
}
const abort = async (): Promise<void> => {
aborted = true
await Promise.allSettled(inflight)
if (backend) await backend.abort().catch(() => {})
}
return {
async write(chunk) {
if (aborted) throw new Error('Multipart upload already aborted')
if (firstError) throw firstError
const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
totalBytes += buf.length
pendingChunks.push(buf)
pendingBytes += buf.length
// Local storage has no multipart concept — accumulate and write once on complete.
if (!cloud) return
while (pendingBytes >= MULTIPART_PART_SIZE) {
const merged = drainPending()
const part = merged.subarray(0, MULTIPART_PART_SIZE)
const rest = merged.subarray(MULTIPART_PART_SIZE)
pendingChunks = rest.length ? [rest] : []
pendingBytes = rest.length
await dispatchPart(part)
}
},
async complete() {
try {
if (!backend) {
// Never crossed one part (or local provider): single-shot upload.
await uploadFile({
file: drainPending(),
fileName: key,
contentType,
context,
preserveKey: true,
customKey: key,
})
return { key, size: totalBytes }
}
if (pendingBytes > 0) await dispatchPart(drainPending())
await Promise.all(inflight)
if (firstError) throw firstError
await backend.finish()
return { key, size: totalBytes }
} catch (err) {
await abort()
throw err
}
},
abort,
}
}
/**
* Download a file from the configured storage provider
*/
export async function downloadFile(options: DownloadFileOptions): Promise<Buffer> {
const { key, context, maxBytes } = options
if (context) {
const config = getStorageConfig(context)
if (USE_BLOB_STORAGE) {
const { downloadFromBlob } = await import('@/lib/uploads/providers/blob/client')
const blobConfig = createBlobConfig(config)
return maxBytes === undefined
? downloadFromBlob(key, blobConfig)
: downloadFromBlob(key, blobConfig, maxBytes)
}
if (USE_S3_STORAGE) {
const { downloadFromS3 } = await import('@/lib/uploads/providers/s3/client')
const s3Config = createS3Config(config)
return maxBytes === undefined
? downloadFromS3(key, s3Config)
: downloadFromS3(key, s3Config, maxBytes)
}
}
const { readFile, stat } = await import('fs/promises')
const { join } = await import('path')
const { UPLOAD_DIR_SERVER } = await import('./setup.server')
const safeKey = sanitizeFileKey(key)
const filePath = join(UPLOAD_DIR_SERVER, safeKey)
if (maxBytes !== undefined) {
const fileStats = await stat(filePath)
assertKnownSizeWithinLimit(fileStats.size, maxBytes, 'storage download')
}
return readFile(filePath)
}
/**
* Stream a file out of the configured storage provider without buffering it in memory.
* The caller MUST fully consume or `destroy()` the returned stream. Used by the large-CSV
* import worker so a multi-hundred-MB file is never held resident.
*/
export async function downloadFileStream(options: {
key: string
context: StorageContext
}): Promise<Readable> {
const { key, context } = options
const config = getStorageConfig(context)
if (USE_BLOB_STORAGE) {
const { downloadFromBlobStream } = await import('@/lib/uploads/providers/blob/client')
return downloadFromBlobStream(key, createBlobConfig(config))
}
if (USE_S3_STORAGE) {
const { downloadFromS3Stream } = await import('@/lib/uploads/providers/s3/client')
return downloadFromS3Stream(key, createS3Config(config))
}
const { createReadStream } = await import('fs')
const { join } = await import('path')
const { UPLOAD_DIR_SERVER } = await import('./setup.server')
return createReadStream(join(UPLOAD_DIR_SERVER, sanitizeFileKey(key)))
}
/**
* Delete a file from the configured storage provider
*/
export async function deleteFile(options: DeleteFileOptions): Promise<void> {
const { key, context } = options
if (context) {
const config = getStorageConfig(context)
if (USE_BLOB_STORAGE) {
const { deleteFromBlob } = await import('@/lib/uploads/providers/blob/client')
return deleteFromBlob(key, createBlobConfig(config))
}
if (USE_S3_STORAGE) {
const { deleteFromS3 } = await import('@/lib/uploads/providers/s3/client')
return deleteFromS3(key, createS3Config(config))
}
}
const { unlink } = await import('fs/promises')
const { join } = await import('path')
const { UPLOAD_DIR_SERVER } = await import('./setup.server')
const safeKey = sanitizeFileKey(key)
const filePath = join(UPLOAD_DIR_SERVER, safeKey)
await unlink(filePath)
}
/** AWS SDK v3 silently caps HTTP connections at 50/endpoint — stay well under. */
const PER_FILE_DELETE_CONCURRENCY = 25
/**
* Bulk delete via the provider's native multi-object API when available
* (S3 `DeleteObjects`), else bounded-concurrency per-file. All keys must
* share `context`. Idempotent on missing keys.
*/
export async function deleteFiles(
keys: string[],
context: StorageContext
): Promise<{ deleted: number; failed: Array<{ key: string; error: string }> }> {
if (keys.length === 0) return { deleted: 0, failed: [] }
const config = getStorageConfig(context)
if (USE_S3_STORAGE) {
const { deleteManyFromS3 } = await import('@/lib/uploads/providers/s3/client')
const { failed } = await deleteManyFromS3(keys, createS3Config(config))
return { deleted: keys.length - failed.length, failed }
}
const failed: Array<{ key: string; error: string }> = []
let cursor = 0
const runWorker = async (): Promise<void> => {
while (cursor < keys.length) {
const idx = cursor++
const key = keys[idx]
try {
await deleteFile({ key, context })
} catch (error) {
failed.push({ key, error: getErrorMessage(error) })
}
}
}
const workerCount = Math.min(PER_FILE_DELETE_CONCURRENCY, keys.length)
await Promise.all(Array.from({ length: workerCount }, runWorker))
return { deleted: keys.length - failed.length, failed }
}
/**
* Check whether an object exists in the configured cloud storage provider.
* Returns object size and content-type when present, or null when missing.
* Throws on errors other than "not found". For local filesystem, returns null.
*/
export async function headObject(
key: string,
context: StorageContext
): Promise<{ size: number; contentType?: string } | null> {
const config = getStorageConfig(context)
if (USE_BLOB_STORAGE) {
const { headBlobObject } = await import('@/lib/uploads/providers/blob/client')
return headBlobObject(key, createBlobConfig(config))
}
if (USE_S3_STORAGE) {
const { headS3Object } = await import('@/lib/uploads/providers/s3/client')
return headS3Object(key, createS3Config(config))
}
return null
}
/**
* Generate a presigned URL for direct file upload
*/
export async function generatePresignedUploadUrl(
options: GeneratePresignedUrlOptions
): Promise<PresignedUrlResponse> {
const {
fileName,
contentType,
fileSize,
context,
userId,
expirationSeconds = 3600,
metadata = {},
customKey,
} = options
const allMetadata = {
...metadata,
originalName: fileName,
uploadedAt: new Date().toISOString(),
purpose: context,
...(userId && { userId }),
}
const config = getStorageConfig(context)
let key: string
if (customKey) {
key = customKey
} else {
const timestamp = Date.now()
const uniqueId = randomBytes(8).toString('hex')
const safeFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
key = `${context}/${timestamp}-${uniqueId}-${safeFileName}`
}
if (USE_S3_STORAGE) {
return generateS3PresignedUrl(
key,
contentType,
fileSize,
allMetadata,
config,
expirationSeconds
)
}
if (USE_BLOB_STORAGE) {
return generateBlobPresignedUrl(key, contentType, allMetadata, config, expirationSeconds)
}
throw new Error('Cloud storage not configured. Cannot generate presigned URL for local storage.')
}
/**
* Generate presigned URL for S3
*/
async function generateS3PresignedUrl(
key: string,
contentType: string,
fileSize: number,
metadata: Record<string, string>,
config: { bucket?: string; region?: string },
expirationSeconds: number
): Promise<PresignedUrlResponse> {
const { getS3Client } = await import('@/lib/uploads/providers/s3/client')
const { PutObjectCommand } = await import('@aws-sdk/client-s3')
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
if (!config.bucket || !config.region) {
throw new Error('S3 configuration missing bucket or region')
}
const sanitizedMetadata = sanitizeStorageMetadata(metadata, 2000)
if (sanitizedMetadata.originalName) {
sanitizedMetadata.originalName = sanitizeFilenameForMetadata(sanitizedMetadata.originalName)
}
const command = new PutObjectCommand({
Bucket: config.bucket,
Key: key,
ContentType: contentType,
ContentLength: fileSize,
Metadata: sanitizedMetadata,
})
const presignedUrl = await getSignedUrl(getS3Client(), command, { expiresIn: expirationSeconds })
return {
url: presignedUrl,
key,
}
}
/**
* Generate presigned URL for Azure Blob
*/
async function generateBlobPresignedUrl(
key: string,
contentType: string,
metadata: Record<string, string>,
config: {
containerName?: string
accountName?: string
accountKey?: string
connectionString?: string
},
expirationSeconds: number
): Promise<PresignedUrlResponse> {
const { getBlobServiceClient, parseConnectionString } = await import(
'@/lib/uploads/providers/blob/client'
)
const { BlobSASPermissions, generateBlobSASQueryParameters, StorageSharedKeyCredential } =
await import('@azure/storage-blob')
if (!config.containerName) {
throw new Error('Blob configuration missing container name')
}
const blobServiceClient = await getBlobServiceClient()
const containerClient = blobServiceClient.getContainerClient(config.containerName)
const blobClient = containerClient.getBlockBlobClient(key)
const startsOn = new Date()
const expiresOn = new Date(startsOn.getTime() + expirationSeconds * 1000)
let accountName = config.accountName
let accountKey = config.accountKey
if ((!accountName || !accountKey) && config.connectionString) {
;({ accountName, accountKey } = parseConnectionString(config.connectionString))
}
if (!accountName || !accountKey) {
throw new Error(
'Azure Blob SAS generation requires accountName/accountKey or a connectionString'
)
}
const sharedKeyCredential = new StorageSharedKeyCredential(accountName, accountKey)
const sasToken = generateBlobSASQueryParameters(
{
containerName: config.containerName,
blobName: key,
permissions: BlobSASPermissions.parse('w'), // write permission for upload
startsOn,
expiresOn,
},
sharedKeyCredential
).toString()
return {
url: `${blobClient.url}?${sasToken}`,
key,
uploadHeaders: {
'x-ms-blob-type': 'BlockBlob',
'x-ms-blob-content-type': contentType,
...Object.entries(metadata).reduce(
(acc, [k, v]) => {
acc[`x-ms-meta-${k}`] = encodeURIComponent(v)
return acc
},
{} as Record<string, string>
),
},
}
}
/**
* Generate multiple presigned URLs at once (batch operation)
*/
export async function generateBatchPresignedUploadUrls(
files: Array<{
fileName: string
contentType: string
fileSize: number
}>,
context: StorageContext,
userId?: string,
expirationSeconds?: number
): Promise<PresignedUrlResponse[]> {
const results: PresignedUrlResponse[] = []
for (const file of files) {
const result = await generatePresignedUploadUrl({
fileName: file.fileName,
contentType: file.contentType,
fileSize: file.fileSize,
context,
userId,
expirationSeconds,
})
results.push(result)
}
return results
}
/**
* Generate a presigned URL for downloading/accessing an existing file
*/
export async function generatePresignedDownloadUrl(
key: string,
context: StorageContext,
expirationSeconds = 3600
): Promise<string> {
const config = getStorageConfig(context)
if (USE_S3_STORAGE) {
const { getPresignedUrlWithConfig } = await import('@/lib/uploads/providers/s3/client')
return getPresignedUrlWithConfig(key, createS3Config(config), expirationSeconds)
}
if (USE_BLOB_STORAGE) {
const { getPresignedUrlWithConfig } = await import('@/lib/uploads/providers/blob/client')
return getPresignedUrlWithConfig(key, createBlobConfig(config), expirationSeconds)
}
const { getBaseUrl } = await import('@/lib/core/utils/urls')
const baseUrl = getBaseUrl()
return `${baseUrl}/api/files/serve/${encodeURIComponent(key)}`
}
/**
* Check if cloud storage is available
*/
export function hasCloudStorage(): boolean {
return USE_BLOB_STORAGE || USE_S3_STORAGE
}
/**
* Get S3 bucket and key information for a storage key
* Useful for services that need direct S3 access (e.g., AWS Textract async)
*/
export function getS3InfoForKey(
key: string,
context: StorageContext
): { bucket: string; key: string } {
if (!USE_S3_STORAGE) {
throw new Error('S3 storage is not configured. Cannot retrieve S3 info for key.')
}
const config = getStorageConfig(context)
if (!config.bucket) {
throw new Error(`S3 bucket not configured for context: ${context}`)
}
return {
bucket: config.bucket,
key,
}
}
+96
View File
@@ -0,0 +1,96 @@
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Base64 } from '@sim/security/hmac'
import { env } from '@/lib/core/config/env'
import type { StorageContext } from '@/lib/uploads/shared/types'
export interface UploadTokenPayload {
uploadId: string
key: string
userId: string
workspaceId: string
context: StorageContext
/** Original file name, carried so the completion handler can record ownership metadata. */
fileName?: string
/** File MIME type, carried for ownership metadata at completion. */
contentType?: string
/** File size in bytes, carried for ownership metadata at completion. */
fileSize?: number
}
interface SignedPayload extends UploadTokenPayload {
exp: number
v: 1
}
const toBase64Url = (input: string): string => Buffer.from(input, 'utf8').toString('base64url')
const fromBase64Url = (input: string): string => Buffer.from(input, 'base64url').toString('utf8')
const sign = (payload: string): string => hmacSha256Base64(payload, env.INTERNAL_API_SECRET)
/**
* Sign an upload session token binding (uploadId, key, userId, workspaceId, context).
* Used to prevent IDOR on multipart upload follow-up calls (get-part-urls, complete, abort).
*/
export function signUploadToken(payload: UploadTokenPayload, expiresInSeconds = 60 * 60): string {
const signed: SignedPayload = {
...payload,
exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
v: 1,
}
const encoded = toBase64Url(JSON.stringify(signed))
return `${encoded}.${sign(encoded)}`
}
export type UploadTokenVerification =
| { valid: true; payload: UploadTokenPayload }
| { valid: false }
export function verifyUploadToken(token: string): UploadTokenVerification {
if (typeof token !== 'string') {
return { valid: false }
}
const parts = token.split('.')
if (parts.length !== 2) return { valid: false }
const [encoded, signature] = parts
if (!encoded || !signature) return { valid: false }
const expected = sign(encoded)
if (!safeCompare(signature, expected)) {
return { valid: false }
}
let parsed: SignedPayload
try {
parsed = JSON.parse(fromBase64Url(encoded)) as SignedPayload
} catch {
return { valid: false }
}
if (
parsed.v !== 1 ||
typeof parsed.exp !== 'number' ||
parsed.exp < Math.floor(Date.now() / 1000) ||
typeof parsed.uploadId !== 'string' ||
typeof parsed.key !== 'string' ||
typeof parsed.userId !== 'string' ||
typeof parsed.workspaceId !== 'string' ||
typeof parsed.context !== 'string'
) {
return { valid: false }
}
return {
valid: true,
payload: {
uploadId: parsed.uploadId,
key: parsed.key,
userId: parsed.userId,
workspaceId: parsed.workspaceId,
context: parsed.context as StorageContext,
...(typeof parsed.fileName === 'string' ? { fileName: parsed.fileName } : {}),
...(typeof parsed.contentType === 'string' ? { contentType: parsed.contentType } : {}),
...(typeof parsed.fileSize === 'number' ? { fileSize: parsed.fileSize } : {}),
},
}
}
+15
View File
@@ -0,0 +1,15 @@
export {
getStorageConfig,
isUsingCloudStorage,
type StorageContext,
} from '@/lib/uploads/config'
export * as ChatFiles from '@/lib/uploads/contexts/chat'
export * as CopilotFiles from '@/lib/uploads/contexts/copilot'
export * as ExecutionFiles from '@/lib/uploads/contexts/execution'
export * as WorkspaceFiles from '@/lib/uploads/contexts/workspace'
export {
getFileMetadata,
getServePathPrefix,
getStorageProvider,
} from '@/lib/uploads/core/storage-client'
export * as StorageService from '@/lib/uploads/core/storage-service'
@@ -0,0 +1,245 @@
/**
* Tests for Azure Blob Storage client
*
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockUpload,
mockDownload,
mockDelete,
mockDeleteIfExists,
mockGetBlockBlobClient,
mockGetContainerClient,
mockFromConnectionString,
mockStorageSharedKeyCredential,
mockGenerateBlobSASQueryParameters,
mockBlobSASPermissionsParse,
} = vi.hoisted(() => ({
mockUpload: vi.fn(),
mockDownload: vi.fn(),
mockDelete: vi.fn(),
mockDeleteIfExists: vi.fn(),
mockGetBlockBlobClient: vi.fn(),
mockGetContainerClient: vi.fn(),
mockFromConnectionString: vi.fn(),
mockStorageSharedKeyCredential: vi.fn(),
mockGenerateBlobSASQueryParameters: vi.fn(),
mockBlobSASPermissionsParse: vi.fn(),
}))
vi.mock('@azure/storage-blob', () => ({
BlobServiceClient: {
fromConnectionString: mockFromConnectionString,
},
StorageSharedKeyCredential: mockStorageSharedKeyCredential,
generateBlobSASQueryParameters: mockGenerateBlobSASQueryParameters,
BlobSASPermissions: {
parse: mockBlobSASPermissionsParse,
},
}))
vi.mock('@/lib/uploads/config', () => ({
BLOB_CONFIG: {
accountName: 'testaccount',
accountKey: 'testkey',
connectionString:
'DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net',
containerName: 'testcontainer',
},
}))
import {
deleteFromBlob,
downloadFromBlob,
getPresignedUrl,
parseConnectionString,
uploadToBlob,
} from '@/lib/uploads/providers/blob/client'
import { sanitizeFilenameForMetadata } from '@/lib/uploads/utils/file-utils'
describe('Azure Blob Storage Client', () => {
beforeEach(() => {
vi.clearAllMocks()
mockBlobSASPermissionsParse.mockReturnValue('r')
mockGetBlockBlobClient.mockReturnValue({
upload: mockUpload,
download: mockDownload,
delete: mockDelete,
deleteIfExists: mockDeleteIfExists,
url: 'https://test.blob.core.windows.net/container/test-file',
})
mockGetContainerClient.mockReturnValue({
getBlockBlobClient: mockGetBlockBlobClient,
})
mockFromConnectionString.mockReturnValue({
getContainerClient: mockGetContainerClient,
})
mockGenerateBlobSASQueryParameters.mockReturnValue({
toString: () => 'sv=2021-06-08&se=2023-01-01T00%3A00%3A00Z&sr=b&sp=r&sig=test',
})
})
describe('uploadToBlob', () => {
it('should upload a file to Azure Blob Storage', async () => {
const testBuffer = Buffer.from('test file content')
const fileName = 'test-file.txt'
const contentType = 'text/plain'
mockUpload.mockResolvedValueOnce({})
const result = await uploadToBlob(testBuffer, fileName, contentType)
expect(mockUpload).toHaveBeenCalledWith(testBuffer, testBuffer.length, {
blobHTTPHeaders: {
blobContentType: contentType,
},
metadata: {
originalName: encodeURIComponent(fileName),
uploadedAt: expect.any(String),
},
})
expect(result).toEqual({
path: expect.stringContaining('/api/files/serve/'),
key: expect.stringContaining(fileName.replace(/\s+/g, '-')),
name: fileName,
size: testBuffer.length,
type: contentType,
})
})
it('should handle custom blob configuration', async () => {
const testBuffer = Buffer.from('test file content')
const fileName = 'test-file.txt'
const contentType = 'text/plain'
const customConfig = {
containerName: 'customcontainer',
accountName: 'customaccount',
accountKey: 'customkey',
}
mockUpload.mockResolvedValueOnce({})
const result = await uploadToBlob(testBuffer, fileName, contentType, customConfig)
expect(mockGetContainerClient).toHaveBeenCalledWith('customcontainer')
expect(result.name).toBe(fileName)
expect(result.type).toBe(contentType)
})
})
describe('downloadFromBlob', () => {
it('should download a file from Azure Blob Storage', async () => {
const testKey = 'test-file-key'
const testContent = Buffer.from('downloaded content')
const mockReadableStream = {
on: vi.fn((event, callback) => {
if (event === 'data') {
callback(testContent)
} else if (event === 'end') {
callback()
}
}),
off: vi.fn(() => mockReadableStream),
}
mockDownload.mockResolvedValueOnce({
readableStreamBody: mockReadableStream,
})
const result = await downloadFromBlob(testKey)
expect(mockGetBlockBlobClient).toHaveBeenCalledWith(testKey)
expect(mockDownload).toHaveBeenCalled()
expect(result).toEqual(testContent)
})
it('should destroy the opened stream when content length exceeds the limit', async () => {
const mockDestroy = vi.fn()
const mockReadableStream = {
destroy: mockDestroy,
on: vi.fn(() => mockReadableStream),
}
mockDownload.mockResolvedValueOnce({
readableStreamBody: mockReadableStream,
contentLength: 1024,
})
await expect(downloadFromBlob('large-file-key', undefined, 10)).rejects.toThrow(
'storage download exceeds maximum size'
)
expect(mockDestroy).toHaveBeenCalledWith(expect.any(Error))
})
})
describe('deleteFromBlob', () => {
it('should delete a file from Azure Blob Storage', async () => {
const testKey = 'test-file-key'
mockDeleteIfExists.mockResolvedValueOnce({})
await deleteFromBlob(testKey)
expect(mockGetBlockBlobClient).toHaveBeenCalledWith(testKey)
expect(mockDeleteIfExists).toHaveBeenCalled()
})
})
describe('getPresignedUrl', () => {
it('should generate a presigned URL for Azure Blob Storage', async () => {
const testKey = 'test-file-key'
const expiresIn = 3600
const result = await getPresignedUrl(testKey, expiresIn)
expect(mockGetBlockBlobClient).toHaveBeenCalledWith(testKey)
expect(mockGenerateBlobSASQueryParameters).toHaveBeenCalled()
expect(result).toContain('https://test.blob.core.windows.net/container/test-file')
expect(result).toContain('sv=2021-06-08')
})
})
describe('parseConnectionString', () => {
it('extracts accountName and accountKey from a well-formed connection string', () => {
const result = parseConnectionString(
'DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=mykey123;EndpointSuffix=core.windows.net'
)
expect(result).toEqual({ accountName: 'myaccount', accountKey: 'mykey123' })
})
it('throws when AccountName is missing', () => {
expect(() =>
parseConnectionString('DefaultEndpointsProtocol=https;AccountKey=mykey123')
).toThrow('Cannot extract account name from connection string')
})
it('throws when AccountKey is missing', () => {
expect(() =>
parseConnectionString('DefaultEndpointsProtocol=https;AccountName=myaccount')
).toThrow('Cannot extract account key from connection string')
})
})
describe('sanitizeFilenameForMetadata', () => {
const testCases = [
{ input: 'test file.txt', expected: 'test file.txt' },
{ input: 'test"file.txt', expected: 'testfile.txt' },
{ input: 'test\\file.txt', expected: 'testfile.txt' },
{ input: 'test file.txt', expected: 'test file.txt' },
{ input: '', expected: 'file' },
]
it.each(testCases)('should sanitize "$input" to "$expected"', ({ input, expected }) => {
expect(sanitizeFilenameForMetadata(input)).toBe(expected)
})
})
})
@@ -0,0 +1,782 @@
import type { Readable } from 'node:stream'
import type { BlobServiceClient as BlobServiceClientType } from '@azure/storage-blob'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import {
assertKnownSizeWithinLimit,
readNodeStreamToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
import { BLOB_CONFIG } from '@/lib/uploads/config'
import type {
AzureMultipartPart,
AzureMultipartUploadInit,
AzurePartUploadUrl,
BlobConfig,
} from '@/lib/uploads/providers/blob/types'
import type { FileInfo } from '@/lib/uploads/shared/types'
import { sanitizeStorageMetadata } from '@/lib/uploads/utils/file-utils'
import { sanitizeFileName } from '@/executor/constants'
const logger = createLogger('BlobClient')
let _blobServiceClient: BlobServiceClientType | null = null
interface ParsedCredentials {
accountName: string
accountKey: string
}
/**
* Extract account name and key from an Azure connection string.
* Connection strings have the format: DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=...
*/
export function parseConnectionString(connectionString: string): ParsedCredentials {
const accountNameMatch = connectionString.match(/AccountName=([^;]+)/)
if (!accountNameMatch) {
throw new Error('Cannot extract account name from connection string')
}
const accountKeyMatch = connectionString.match(/AccountKey=([^;]+)/)
if (!accountKeyMatch) {
throw new Error('Cannot extract account key from connection string')
}
return {
accountName: accountNameMatch[1],
accountKey: accountKeyMatch[1],
}
}
/**
* Get account credentials from BLOB_CONFIG, extracting from connection string if necessary.
*/
function getAccountCredentials(): ParsedCredentials {
if (BLOB_CONFIG.connectionString) {
return parseConnectionString(BLOB_CONFIG.connectionString)
}
if (BLOB_CONFIG.accountName && BLOB_CONFIG.accountKey) {
return {
accountName: BLOB_CONFIG.accountName,
accountKey: BLOB_CONFIG.accountKey,
}
}
throw new Error(
'Azure Blob Storage credentials are missing set AZURE_CONNECTION_STRING or both AZURE_ACCOUNT_NAME and AZURE_ACCOUNT_KEY'
)
}
export async function getBlobServiceClient(): Promise<BlobServiceClientType> {
if (_blobServiceClient) return _blobServiceClient
const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob')
const { accountName, accountKey, connectionString } = BLOB_CONFIG
if (connectionString) {
_blobServiceClient = BlobServiceClient.fromConnectionString(connectionString)
} else if (accountName && accountKey) {
const sharedKeyCredential = new StorageSharedKeyCredential(accountName, accountKey)
_blobServiceClient = new BlobServiceClient(
`https://${accountName}.blob.core.windows.net`,
sharedKeyCredential
)
} else {
throw new Error(
'Azure Blob Storage credentials are missing set AZURE_STORAGE_CONNECTION_STRING or both AZURE_STORAGE_ACCOUNT_NAME and AZURE_STORAGE_ACCOUNT_KEY in your environment.'
)
}
return _blobServiceClient
}
/**
* Upload a file to Azure Blob Storage
* @param file Buffer containing file data
* @param fileName Original file name
* @param contentType MIME type of the file
* @param configOrSize Custom Blob configuration OR file size in bytes (optional)
* @param size File size in bytes (required if configOrSize is BlobConfig, optional otherwise)
* @param preserveKey Preserve the fileName as the storage key without adding timestamp prefix (default: false)
* @param metadata Optional metadata to store with the file
* @returns Object with file information
*/
export async function uploadToBlob(
file: Buffer,
fileName: string,
contentType: string,
configOrSize?: BlobConfig | number,
size?: number,
preserveKey?: boolean,
metadata?: Record<string, string>
): Promise<FileInfo> {
let config: BlobConfig
let fileSize: number
let shouldPreserveKey: boolean
if (typeof configOrSize === 'object') {
config = configOrSize
fileSize = size ?? file.length
shouldPreserveKey = preserveKey ?? false
} else {
config = {
containerName: BLOB_CONFIG.containerName,
accountName: BLOB_CONFIG.accountName,
accountKey: BLOB_CONFIG.accountKey,
connectionString: BLOB_CONFIG.connectionString,
}
fileSize = configOrSize ?? file.length
shouldPreserveKey = preserveKey ?? false
}
const safeFileName = sanitizeFileName(fileName)
const uniqueKey = shouldPreserveKey ? fileName : `${Date.now()}-${safeFileName}`
const blobServiceClient = await getBlobServiceClient()
const containerClient = blobServiceClient.getContainerClient(config.containerName)
const blockBlobClient = containerClient.getBlockBlobClient(uniqueKey)
const blobMetadata: Record<string, string> = {
originalName: encodeURIComponent(fileName), // Encode filename to prevent invalid characters in HTTP headers
uploadedAt: new Date().toISOString(),
}
if (metadata) {
Object.assign(blobMetadata, sanitizeStorageMetadata(metadata, 8000))
}
await blockBlobClient.upload(file, fileSize, {
blobHTTPHeaders: {
blobContentType: contentType,
},
metadata: blobMetadata,
})
const servePath = `/api/files/serve/${encodeURIComponent(uniqueKey)}`
return {
path: servePath,
key: uniqueKey,
name: fileName,
size: fileSize,
type: contentType,
}
}
/**
* Generate a presigned URL for direct file access
* @param key Blob name
* @param expiresIn Time in seconds until URL expires
* @returns Presigned URL
*/
export async function getPresignedUrl(key: string, expiresIn = 3600) {
const { BlobSASPermissions, generateBlobSASQueryParameters, StorageSharedKeyCredential } =
await import('@azure/storage-blob')
const blobServiceClient = await getBlobServiceClient()
const containerClient = blobServiceClient.getContainerClient(BLOB_CONFIG.containerName)
const blockBlobClient = containerClient.getBlockBlobClient(key)
const { accountName, accountKey } = getAccountCredentials()
const sasOptions = {
containerName: BLOB_CONFIG.containerName,
blobName: key,
permissions: BlobSASPermissions.parse('r'), // Read permission
startsOn: new Date(),
expiresOn: new Date(Date.now() + expiresIn * 1000),
}
const sasToken = generateBlobSASQueryParameters(
sasOptions,
new StorageSharedKeyCredential(accountName, accountKey)
).toString()
return `${blockBlobClient.url}?${sasToken}`
}
/**
* Generate a presigned URL for direct file access with custom container
* @param key Blob name
* @param customConfig Custom Blob configuration
* @param expiresIn Time in seconds until URL expires
* @returns Presigned URL
*/
export async function getPresignedUrlWithConfig(
key: string,
customConfig: BlobConfig,
expiresIn = 3600
) {
const {
BlobServiceClient,
BlobSASPermissions,
generateBlobSASQueryParameters,
StorageSharedKeyCredential,
} = await import('@azure/storage-blob')
let tempBlobServiceClient: BlobServiceClientType
let accountName: string
let accountKey: string
if (customConfig.connectionString) {
tempBlobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString)
const credentials = parseConnectionString(customConfig.connectionString)
accountName = credentials.accountName
accountKey = credentials.accountKey
} else if (customConfig.accountName && customConfig.accountKey) {
const sharedKeyCredential = new StorageSharedKeyCredential(
customConfig.accountName,
customConfig.accountKey
)
tempBlobServiceClient = new BlobServiceClient(
`https://${customConfig.accountName}.blob.core.windows.net`,
sharedKeyCredential
)
accountName = customConfig.accountName
accountKey = customConfig.accountKey
} else {
throw new Error(
'Custom blob config must include either connectionString or accountName + accountKey'
)
}
const containerClient = tempBlobServiceClient.getContainerClient(customConfig.containerName)
const blockBlobClient = containerClient.getBlockBlobClient(key)
const sasOptions = {
containerName: customConfig.containerName,
blobName: key,
permissions: BlobSASPermissions.parse('r'), // Read permission
startsOn: new Date(),
expiresOn: new Date(Date.now() + expiresIn * 1000),
}
const sasToken = generateBlobSASQueryParameters(
sasOptions,
new StorageSharedKeyCredential(accountName, accountKey)
).toString()
return `${blockBlobClient.url}?${sasToken}`
}
/**
* Download a file from Azure Blob Storage
* @param key Blob name
* @returns File buffer
*/
export async function downloadFromBlob(key: string): Promise<Buffer>
/**
* Download a file from Azure Blob Storage with custom configuration
* @param key Blob name
* @param customConfig Custom Blob configuration
* @returns File buffer
*/
export async function downloadFromBlob(key: string, customConfig: BlobConfig): Promise<Buffer>
export async function downloadFromBlob(
key: string,
customConfig: BlobConfig,
maxBytes: number
): Promise<Buffer>
export async function downloadFromBlob(
key: string,
customConfig?: BlobConfig,
maxBytes?: number
): Promise<Buffer> {
const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob')
let blobServiceClient: BlobServiceClientType
let containerName: string
if (customConfig) {
if (customConfig.connectionString) {
blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString)
} else if (customConfig.accountName && customConfig.accountKey) {
const credential = new StorageSharedKeyCredential(
customConfig.accountName,
customConfig.accountKey
)
blobServiceClient = new BlobServiceClient(
`https://${customConfig.accountName}.blob.core.windows.net`,
credential
)
} else {
throw new Error('Invalid custom blob configuration')
}
containerName = customConfig.containerName
} else {
blobServiceClient = await getBlobServiceClient()
containerName = BLOB_CONFIG.containerName
}
const containerClient = blobServiceClient.getContainerClient(containerName)
const blockBlobClient = containerClient.getBlockBlobClient(key)
const downloadBlockBlobResponse = await blockBlobClient.download()
if (maxBytes !== undefined && downloadBlockBlobResponse.contentLength !== undefined) {
try {
assertKnownSizeWithinLimit(
downloadBlockBlobResponse.contentLength,
maxBytes,
'storage download'
)
} catch (error) {
const stream = downloadBlockBlobResponse.readableStreamBody as
| { destroy?: (error?: Error) => void }
| undefined
stream?.destroy?.(error instanceof Error ? error : undefined)
throw error
}
}
if (!downloadBlockBlobResponse.readableStreamBody) {
throw new Error('Failed to get readable stream from blob download')
}
const downloaded = await readNodeStreamToBufferWithLimit(
downloadBlockBlobResponse.readableStreamBody,
{
maxBytes: maxBytes ?? Number.MAX_SAFE_INTEGER,
label: 'storage download',
}
)
return downloaded
}
/**
* Stream a blob out of storage without buffering it. The caller MUST fully consume or
* `destroy()` the returned stream. Used by the large-CSV import worker.
*/
export async function downloadFromBlobStream(
key: string,
customConfig?: BlobConfig
): Promise<Readable> {
const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob')
let blobServiceClient: BlobServiceClientType
let containerName: string
if (customConfig) {
if (customConfig.connectionString) {
blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString)
} else if (customConfig.accountName && customConfig.accountKey) {
const credential = new StorageSharedKeyCredential(
customConfig.accountName,
customConfig.accountKey
)
blobServiceClient = new BlobServiceClient(
`https://${customConfig.accountName}.blob.core.windows.net`,
credential
)
} else {
throw new Error('Invalid custom blob configuration')
}
containerName = customConfig.containerName
} else {
blobServiceClient = await getBlobServiceClient()
containerName = BLOB_CONFIG.containerName
}
const containerClient = blobServiceClient.getContainerClient(containerName)
const blockBlobClient = containerClient.getBlockBlobClient(key)
const downloadBlockBlobResponse = await blockBlobClient.download()
if (!downloadBlockBlobResponse.readableStreamBody) {
throw new Error('Failed to get readable stream from blob download')
}
return downloadBlockBlobResponse.readableStreamBody as Readable
}
/**
* Check whether a blob exists (and return its size when it does).
* Returns null when the blob is missing.
*/
export async function headBlobObject(
key: string,
customConfig?: BlobConfig
): Promise<{ size: number; contentType?: string } | null> {
const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob')
let blobServiceClient: BlobServiceClientType
let containerName: string
if (customConfig) {
if (customConfig.connectionString) {
blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString)
} else if (customConfig.accountName && customConfig.accountKey) {
const credential = new StorageSharedKeyCredential(
customConfig.accountName,
customConfig.accountKey
)
blobServiceClient = new BlobServiceClient(
`https://${customConfig.accountName}.blob.core.windows.net`,
credential
)
} else {
throw new Error('Invalid custom blob configuration')
}
containerName = customConfig.containerName
} else {
blobServiceClient = await getBlobServiceClient()
containerName = BLOB_CONFIG.containerName
}
const containerClient = blobServiceClient.getContainerClient(containerName)
const blockBlobClient = containerClient.getBlockBlobClient(key)
try {
const properties = await blockBlobClient.getProperties()
return {
size: properties.contentLength ?? 0,
contentType: properties.contentType,
}
} catch (err) {
const status = (err as { statusCode?: number }).statusCode
const code = (err as { code?: string }).code
if (status === 404 || code === 'BlobNotFound') {
return null
}
throw err
}
}
/**
* Delete a file from Azure Blob Storage
* @param key Blob name
*/
export async function deleteFromBlob(key: string): Promise<void>
/**
* Delete a file from Azure Blob Storage with custom configuration
* @param key Blob name
* @param customConfig Custom Blob configuration
*/
export async function deleteFromBlob(key: string, customConfig: BlobConfig): Promise<void>
export async function deleteFromBlob(key: string, customConfig?: BlobConfig): Promise<void> {
const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob')
let blobServiceClient: BlobServiceClientType
let containerName: string
if (customConfig) {
if (customConfig.connectionString) {
blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString)
} else if (customConfig.accountName && customConfig.accountKey) {
const credential = new StorageSharedKeyCredential(
customConfig.accountName,
customConfig.accountKey
)
blobServiceClient = new BlobServiceClient(
`https://${customConfig.accountName}.blob.core.windows.net`,
credential
)
} else {
throw new Error('Invalid custom blob configuration')
}
containerName = customConfig.containerName
} else {
blobServiceClient = await getBlobServiceClient()
containerName = BLOB_CONFIG.containerName
}
const containerClient = blobServiceClient.getContainerClient(containerName)
const blockBlobClient = containerClient.getBlockBlobClient(key)
await blockBlobClient.deleteIfExists()
}
/**
* Derive the deterministic Azure block id for a given part number.
* Block ids must be base64-encoded and equal length within an upload; using a
* fixed-width zero-padded counter gives both properties for free, and lets the
* server reconstruct the id from `partNumber` alone when completing an upload.
*/
export function deriveBlobBlockId(partNumber: number): string {
return Buffer.from(`block-${partNumber.toString().padStart(6, '0')}`).toString('base64')
}
/**
* Initiate a multipart upload for Azure Blob Storage
*/
export async function initiateMultipartUpload(
options: AzureMultipartUploadInit
): Promise<{ uploadId: string; key: string }> {
const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob')
const { fileName, contentType, customConfig, customKey } = options
let blobServiceClient: BlobServiceClientType
let containerName: string
if (customConfig) {
if (customConfig.connectionString) {
blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString)
} else if (customConfig.accountName && customConfig.accountKey) {
const credential = new StorageSharedKeyCredential(
customConfig.accountName,
customConfig.accountKey
)
blobServiceClient = new BlobServiceClient(
`https://${customConfig.accountName}.blob.core.windows.net`,
credential
)
} else {
throw new Error('Invalid custom blob configuration')
}
containerName = customConfig.containerName
} else {
blobServiceClient = await getBlobServiceClient()
containerName = BLOB_CONFIG.containerName
}
const safeFileName = sanitizeFileName(fileName)
const uniqueKey = customKey || `kb/${generateId()}-${safeFileName}`
const uploadId = generateId()
const containerClient = blobServiceClient.getContainerClient(containerName)
const blockBlobClient = containerClient.getBlockBlobClient(uniqueKey)
await blockBlobClient.setMetadata({
uploadId,
fileName: encodeURIComponent(fileName),
contentType,
uploadStarted: new Date().toISOString(),
multipartUpload: 'true',
})
return {
uploadId,
key: uniqueKey,
}
}
/**
* Generate presigned URLs for uploading parts
*/
export async function getMultipartPartUrls(
key: string,
partNumbers: number[],
customConfig?: BlobConfig
): Promise<AzurePartUploadUrl[]> {
const {
BlobServiceClient,
BlobSASPermissions,
generateBlobSASQueryParameters,
StorageSharedKeyCredential,
} = await import('@azure/storage-blob')
let blobServiceClient: BlobServiceClientType
let containerName: string
let accountName: string
let accountKey: string
if (customConfig) {
if (customConfig.connectionString) {
blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString)
const credentials = parseConnectionString(customConfig.connectionString)
accountName = credentials.accountName
accountKey = credentials.accountKey
} else if (customConfig.accountName && customConfig.accountKey) {
const credential = new StorageSharedKeyCredential(
customConfig.accountName,
customConfig.accountKey
)
blobServiceClient = new BlobServiceClient(
`https://${customConfig.accountName}.blob.core.windows.net`,
credential
)
accountName = customConfig.accountName
accountKey = customConfig.accountKey
} else {
throw new Error('Invalid custom blob configuration')
}
containerName = customConfig.containerName
} else {
blobServiceClient = await getBlobServiceClient()
containerName = BLOB_CONFIG.containerName
const credentials = getAccountCredentials()
accountName = credentials.accountName
accountKey = credentials.accountKey
}
const containerClient = blobServiceClient.getContainerClient(containerName)
const blockBlobClient = containerClient.getBlockBlobClient(key)
return partNumbers.map((partNumber) => {
const blockId = deriveBlobBlockId(partNumber)
const sasOptions = {
containerName,
blobName: key,
permissions: BlobSASPermissions.parse('w'), // Write permission
startsOn: new Date(),
expiresOn: new Date(Date.now() + 3600 * 1000), // 1 hour
}
const sasToken = generateBlobSASQueryParameters(
sasOptions,
new StorageSharedKeyCredential(accountName, accountKey)
).toString()
return {
partNumber,
blockId,
url: `${blockBlobClient.url}?comp=block&blockid=${encodeURIComponent(blockId)}&${sasToken}`,
}
})
}
async function getBlockBlobClientFor(key: string, customConfig?: BlobConfig) {
const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob')
let blobServiceClient: BlobServiceClientType
let containerName: string
if (customConfig) {
if (customConfig.connectionString) {
blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString)
} else if (customConfig.accountName && customConfig.accountKey) {
const credential = new StorageSharedKeyCredential(
customConfig.accountName,
customConfig.accountKey
)
blobServiceClient = new BlobServiceClient(
`https://${customConfig.accountName}.blob.core.windows.net`,
credential
)
} else {
throw new Error('Invalid custom blob configuration')
}
containerName = customConfig.containerName
} else {
blobServiceClient = await getBlobServiceClient()
containerName = BLOB_CONFIG.containerName
}
return blobServiceClient.getContainerClient(containerName).getBlockBlobClient(key)
}
/**
* Stage a single block from the server (body in hand), returning its
* `{ partNumber, blockId }`. The server-side streaming counterpart to the
* presigned {@link getMultipartPartUrls}.
*/
export async function stageBlobPart(
key: string,
partNumber: number,
body: Buffer,
customConfig?: BlobConfig
): Promise<AzureMultipartPart> {
const blockBlobClient = await getBlockBlobClientFor(key, customConfig)
const blockId = deriveBlobBlockId(partNumber)
await blockBlobClient.stageBlock(blockId, body, body.length)
return { partNumber, blockId }
}
/**
* Commit staged blocks into the final blob, setting the content type. Used by the
* server-side streaming uploader (the KB flow uses {@link completeMultipartUpload}).
*/
export async function commitBlobBlockList(
key: string,
parts: AzureMultipartPart[],
contentType: string,
customConfig?: BlobConfig
): Promise<void> {
const blockBlobClient = await getBlockBlobClientFor(key, customConfig)
const sortedBlockIds = parts.sort((a, b) => a.partNumber - b.partNumber).map((p) => p.blockId)
await blockBlobClient.commitBlockList(sortedBlockIds, {
blobHTTPHeaders: { blobContentType: contentType },
})
}
/**
* Complete multipart upload by committing all blocks
*/
export async function completeMultipartUpload(
key: string,
parts: AzureMultipartPart[],
customConfig?: BlobConfig
): Promise<{ location: string; path: string; key: string }> {
const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob')
let blobServiceClient: BlobServiceClientType
let containerName: string
if (customConfig) {
if (customConfig.connectionString) {
blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString)
} else if (customConfig.accountName && customConfig.accountKey) {
const credential = new StorageSharedKeyCredential(
customConfig.accountName,
customConfig.accountKey
)
blobServiceClient = new BlobServiceClient(
`https://${customConfig.accountName}.blob.core.windows.net`,
credential
)
} else {
throw new Error('Invalid custom blob configuration')
}
containerName = customConfig.containerName
} else {
blobServiceClient = await getBlobServiceClient()
containerName = BLOB_CONFIG.containerName
}
const containerClient = blobServiceClient.getContainerClient(containerName)
const blockBlobClient = containerClient.getBlockBlobClient(key)
const sortedBlockIds = parts
.sort((a, b) => a.partNumber - b.partNumber)
.map((part) => part.blockId)
await blockBlobClient.commitBlockList(sortedBlockIds, {
metadata: {
multipartUpload: 'completed',
uploadCompletedAt: new Date().toISOString(),
},
})
const location = blockBlobClient.url
const path = `/api/files/serve/${encodeURIComponent(key)}`
return {
location,
path,
key,
}
}
/**
* Abort multipart upload by deleting the blob if it exists
*/
export async function abortMultipartUpload(key: string, customConfig?: BlobConfig): Promise<void> {
const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob')
let blobServiceClient: BlobServiceClientType
let containerName: string
if (customConfig) {
if (customConfig.connectionString) {
blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString)
} else if (customConfig.accountName && customConfig.accountKey) {
const credential = new StorageSharedKeyCredential(
customConfig.accountName,
customConfig.accountKey
)
blobServiceClient = new BlobServiceClient(
`https://${customConfig.accountName}.blob.core.windows.net`,
credential
)
} else {
throw new Error('Invalid custom blob configuration')
}
containerName = customConfig.containerName
} else {
blobServiceClient = await getBlobServiceClient()
containerName = BLOB_CONFIG.containerName
}
const containerClient = blobServiceClient.getContainerClient(containerName)
const blockBlobClient = containerClient.getBlockBlobClient(key)
try {
await blockBlobClient.deleteIfExists()
} catch (error) {
logger.warn('Error cleaning up multipart upload:', error)
}
}
@@ -0,0 +1,29 @@
export interface BlobConfig {
containerName: string
accountName?: string
accountKey?: string
connectionString?: string
}
export interface AzureMultipartUploadInit {
fileName: string
contentType: string
fileSize: number
customConfig?: BlobConfig
/**
* When provided, overrides the default `kb/${id}-${name}` key derivation.
* Caller is responsible for uniqueness and prefix conventions.
*/
customKey?: string
}
export interface AzurePartUploadUrl {
partNumber: number
blockId: string
url: string
}
export interface AzureMultipartPart {
blockId: string
partNumber: number
}
@@ -0,0 +1,471 @@
/**
* Tests for S3 client functionality
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockSend,
mockS3Client,
mockS3ClientConstructor,
mockPutObjectCommand,
mockGetObjectCommand,
mockDeleteObjectCommand,
mockCompleteMultipartUploadCommand,
mockGetSignedUrl,
mockEnv,
mockS3Config,
} = vi.hoisted(() => {
const mockSend = vi.fn()
const mockS3Client = { send: mockSend }
const mockEnv: Record<string, string | undefined> = {
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
S3_BUCKET_NAME: 'test-bucket',
AWS_REGION: 'test-region',
AWS_ACCESS_KEY_ID: 'test-access-key',
AWS_SECRET_ACCESS_KEY: 'test-secret-key',
}
const mockS3Config: {
bucket: string
region: string
endpoint: string | undefined
forcePathStyle: boolean
} = {
bucket: 'test-bucket',
region: 'test-region',
endpoint: undefined,
forcePathStyle: false,
}
return {
mockSend,
mockS3Client,
mockS3Config,
mockS3ClientConstructor: vi.fn().mockImplementation(
class {
constructor() {
// biome-ignore lint/correctness/noConstructorReturn: vitest 4 constructs mocks via Reflect.construct; returning the object overrides the instance so `new S3Client()` yields the shared mock the tests assert on
return mockS3Client
}
}
),
mockPutObjectCommand: vi.fn().mockImplementation(class {}),
mockGetObjectCommand: vi.fn().mockImplementation(class {}),
mockDeleteObjectCommand: vi.fn().mockImplementation(class {}),
mockCompleteMultipartUploadCommand: vi.fn().mockImplementation(class {}),
mockGetSignedUrl: vi.fn(),
mockEnv,
}
})
vi.mock('@aws-sdk/client-s3', () => ({
S3Client: mockS3ClientConstructor,
PutObjectCommand: mockPutObjectCommand,
GetObjectCommand: mockGetObjectCommand,
DeleteObjectCommand: mockDeleteObjectCommand,
CompleteMultipartUploadCommand: mockCompleteMultipartUploadCommand,
}))
vi.mock('@aws-sdk/s3-request-presigner', () => ({
getSignedUrl: mockGetSignedUrl,
}))
vi.mock('@/lib/core/config/env', () => ({
env: mockEnv,
getEnv: (key: string) => mockEnv[key],
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value),
isFalsy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false,
}))
vi.mock('@/lib/uploads/setup', () => ({
S3_CONFIG: {
bucket: 'test-bucket',
region: 'test-region',
},
}))
vi.mock('@/lib/uploads/config', () => ({
S3_CONFIG: mockS3Config,
S3_KB_CONFIG: {
bucket: 'test-kb-bucket',
region: 'test-region',
},
}))
import {
completeS3MultipartUpload,
deleteFromS3,
downloadFromS3,
getPresignedUrl,
getS3Client,
resetS3ClientForTesting,
uploadToS3,
} from '@/lib/uploads/providers/s3/client'
describe('S3 Client', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(Date, 'now').mockReturnValue(1672603200000)
vi.spyOn(Date.prototype, 'toISOString').mockReturnValue('2025-06-16T01:13:10.765Z')
mockEnv.AWS_ACCESS_KEY_ID = 'test-access-key'
mockEnv.AWS_SECRET_ACCESS_KEY = 'test-secret-key'
mockS3Config.endpoint = undefined
mockS3Config.forcePathStyle = false
resetS3ClientForTesting()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('uploadToS3', () => {
it('should upload a file to S3 and return file info', async () => {
mockSend.mockResolvedValueOnce({})
const file = Buffer.from('test content')
const fileName = 'test-file.txt'
const contentType = 'text/plain'
const result = await uploadToS3(file, fileName, contentType)
expect(mockPutObjectCommand).toHaveBeenCalledWith({
Bucket: 'test-bucket',
Key: expect.stringContaining('test-file.txt'),
Body: file,
ContentType: 'text/plain',
Metadata: {
originalName: 'test-file.txt',
uploadedAt: expect.any(String),
},
})
expect(mockSend).toHaveBeenCalledWith(expect.any(Object))
expect(result).toEqual({
path: expect.stringContaining('/api/files/serve/'),
key: expect.stringContaining('test-file.txt'),
name: 'test-file.txt',
size: file.length,
type: 'text/plain',
})
})
it('should handle spaces in filenames', async () => {
mockSend.mockResolvedValueOnce({})
const testFile = Buffer.from('test file content')
const fileName = 'test file with spaces.txt'
const contentType = 'text/plain'
const result = await uploadToS3(testFile, fileName, contentType)
expect(mockPutObjectCommand).toHaveBeenCalledWith(
expect.objectContaining({
Key: expect.stringContaining('test-file-with-spaces.txt'),
})
)
expect(result.name).toBe(fileName)
})
it('should use provided size if available', async () => {
mockSend.mockResolvedValueOnce({})
const testFile = Buffer.from('test file content')
const fileName = 'test-file.txt'
const contentType = 'text/plain'
const providedSize = 1000
const result = await uploadToS3(testFile, fileName, contentType, providedSize)
expect(result.size).toBe(providedSize)
})
it('should handle upload errors', async () => {
const error = new Error('Upload failed')
mockSend.mockRejectedValueOnce(error)
const testFile = Buffer.from('test file content')
const fileName = 'test-file.txt'
const contentType = 'text/plain'
await expect(uploadToS3(testFile, fileName, contentType)).rejects.toThrow('Upload failed')
})
})
describe('getPresignedUrl', () => {
it('should generate a presigned URL for a file', async () => {
mockGetSignedUrl.mockResolvedValueOnce('https://example.com/presigned-url')
const key = 'test-file.txt'
const expiresIn = 1800
const url = await getPresignedUrl(key, expiresIn)
expect(mockGetObjectCommand).toHaveBeenCalledWith({
Bucket: 'test-bucket',
Key: key,
})
expect(mockGetSignedUrl).toHaveBeenCalledWith(mockS3Client, expect.any(Object), { expiresIn })
expect(url).toBe('https://example.com/presigned-url')
})
it('should use default expiration if not provided', async () => {
mockGetSignedUrl.mockResolvedValueOnce('https://example.com/presigned-url')
const key = 'test-file.txt'
await getPresignedUrl(key)
expect(mockGetSignedUrl).toHaveBeenCalledWith(mockS3Client, expect.any(Object), {
expiresIn: 3600,
})
})
it('should handle errors when generating presigned URL', async () => {
const error = new Error('Presigned URL generation failed')
mockGetSignedUrl.mockRejectedValueOnce(error)
const key = 'test-file.txt'
await expect(getPresignedUrl(key)).rejects.toThrow('Presigned URL generation failed')
})
})
describe('downloadFromS3', () => {
it('should download a file from S3', async () => {
const mockStream = {
on: vi.fn((event, callback) => {
if (event === 'data') {
callback(Buffer.from('chunk1'))
callback(Buffer.from('chunk2'))
}
if (event === 'end') {
callback()
}
return mockStream
}),
off: vi.fn(() => mockStream),
}
mockSend.mockResolvedValueOnce({
Body: mockStream,
$metadata: { httpStatusCode: 200 },
})
const key = 'test-file.txt'
const result = await downloadFromS3(key)
expect(mockGetObjectCommand).toHaveBeenCalledWith({
Bucket: 'test-bucket',
Key: key,
})
expect(mockSend).toHaveBeenCalledTimes(1)
expect(result).toBeInstanceOf(Buffer)
expect(result.toString()).toBe('chunk1chunk2')
})
it('should handle stream errors', async () => {
const mockStream = {
on: vi.fn((event, callback) => {
if (event === 'error') {
callback(new Error('Stream error'))
}
return mockStream
}),
off: vi.fn(() => mockStream),
}
mockSend.mockResolvedValueOnce({
Body: mockStream,
$metadata: { httpStatusCode: 200 },
})
const key = 'test-file.txt'
await expect(downloadFromS3(key)).rejects.toThrow('Stream error')
})
it('should destroy the opened stream when content length exceeds the limit', async () => {
const mockDestroy = vi.fn()
const mockStream = {
destroy: mockDestroy,
on: vi.fn(() => mockStream),
}
mockSend.mockResolvedValueOnce({
Body: mockStream,
ContentLength: 1024,
$metadata: { httpStatusCode: 200 },
})
await expect(
downloadFromS3('large-file.txt', { bucket: 'test-bucket', region: 'test-region' }, 10)
).rejects.toThrow('storage download exceeds maximum size')
expect(mockDestroy).toHaveBeenCalledWith(expect.any(Error))
})
it('should handle S3 client errors', async () => {
const error = new Error('Download failed')
mockSend.mockRejectedValueOnce(error)
const key = 'test-file.txt'
await expect(downloadFromS3(key)).rejects.toThrow('Download failed')
})
})
describe('deleteFromS3', () => {
it('should delete a file from S3', async () => {
mockSend.mockResolvedValueOnce({})
const key = 'test-file.txt'
await deleteFromS3(key)
expect(mockDeleteObjectCommand).toHaveBeenCalledWith({
Bucket: 'test-bucket',
Key: key,
})
expect(mockSend).toHaveBeenCalledTimes(1)
})
it('should handle delete errors', async () => {
const error = new Error('Delete failed')
mockSend.mockRejectedValueOnce(error)
const key = 'test-file.txt'
await expect(deleteFromS3(key)).rejects.toThrow('Delete failed')
})
})
describe('s3Client initialization', () => {
it('should initialize with correct configuration when credentials are available', () => {
mockEnv.AWS_ACCESS_KEY_ID = 'test-access-key'
mockEnv.AWS_SECRET_ACCESS_KEY = 'test-secret-key'
resetS3ClientForTesting()
const client = getS3Client()
expect(client).toBeDefined()
expect(mockS3ClientConstructor).toHaveBeenCalledWith({
region: 'test-region',
endpoint: undefined,
forcePathStyle: false,
credentials: {
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
},
})
})
it('should initialize without credentials when env vars are not available', () => {
mockEnv.AWS_ACCESS_KEY_ID = undefined
mockEnv.AWS_SECRET_ACCESS_KEY = undefined
resetS3ClientForTesting()
const client = getS3Client()
expect(client).toBeDefined()
expect(mockS3ClientConstructor).toHaveBeenCalledWith({
region: 'test-region',
endpoint: undefined,
forcePathStyle: false,
credentials: undefined,
})
})
it('should pass a custom endpoint and path-style flag for S3-compatible providers', () => {
mockS3Config.endpoint = 'https://account.r2.cloudflarestorage.com'
mockS3Config.forcePathStyle = true
resetS3ClientForTesting()
const client = getS3Client()
expect(client).toBeDefined()
expect(mockS3ClientConstructor).toHaveBeenCalledWith({
region: 'test-region',
endpoint: 'https://account.r2.cloudflarestorage.com',
forcePathStyle: true,
credentials: {
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
},
})
})
})
describe('completeS3MultipartUpload fallback location', () => {
const parts = [{ ETag: 'etag-1', PartNumber: 1 }]
it('uses the SDK-provided Location when present', async () => {
mockSend.mockResolvedValueOnce({ Location: 'https://provided.example.com/object' })
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
expect(result.location).toBe('https://provided.example.com/object')
expect(result.key).toBe('kb/uuid-file.txt')
expect(result.path).toBe('/api/files/serve/kb%2Fuuid-file.txt')
})
it('falls back to an AWS virtual-hosted URL when Location is absent', async () => {
mockSend.mockResolvedValueOnce({})
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
expect(result.location).toBe(
'https://test-kb-bucket.s3.test-region.amazonaws.com/kb/uuid-file.txt'
)
})
it('builds a path-style fallback URL for a custom endpoint with forcePathStyle', async () => {
mockS3Config.endpoint = 'https://minio.example.com'
mockS3Config.forcePathStyle = true
mockSend.mockResolvedValueOnce({})
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
expect(result.location).toBe('https://minio.example.com/test-kb-bucket/kb/uuid-file.txt')
})
it('builds a virtual-hosted fallback URL for a custom endpoint without forcePathStyle', async () => {
mockS3Config.endpoint = 'https://account.r2.cloudflarestorage.com'
mockS3Config.forcePathStyle = false
mockSend.mockResolvedValueOnce({})
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
expect(result.location).toBe(
'https://test-kb-bucket.account.r2.cloudflarestorage.com/kb/uuid-file.txt'
)
})
it('strips a trailing slash from the custom endpoint before appending the key', async () => {
mockS3Config.endpoint = 'https://minio.example.com/'
mockS3Config.forcePathStyle = true
mockSend.mockResolvedValueOnce({})
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
expect(result.location).toBe('https://minio.example.com/test-kb-bucket/kb/uuid-file.txt')
})
it('percent-encodes special characters per path segment, preserving slashes', async () => {
mockSend.mockResolvedValueOnce({})
const result = await completeS3MultipartUpload('kb/uuid-my file.txt', 'upload-1', parts)
expect(result.location).toBe(
'https://test-kb-bucket.s3.test-region.amazonaws.com/kb/uuid-my%20file.txt'
)
})
})
})
+509
View File
@@ -0,0 +1,509 @@
import type { Readable } from 'node:stream'
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
UploadPartCommand,
} from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws'
import {
assertKnownSizeWithinLimit,
readNodeStreamToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
import { S3_CONFIG, S3_KB_CONFIG } from '@/lib/uploads/config'
import type {
S3Config,
S3MultipartPart,
S3MultipartUploadInit,
S3PartUploadUrl,
} from '@/lib/uploads/providers/s3/types'
import type { FileInfo } from '@/lib/uploads/shared/types'
import {
sanitizeFilenameForMetadata,
sanitizeStorageMetadata,
} from '@/lib/uploads/utils/file-utils'
import { sanitizeFileName } from '@/executor/constants'
let _s3Client: S3Client | null = null
/**
* Reset the cached S3 client. Only intended for use in tests.
*/
export function resetS3ClientForTesting(): void {
_s3Client = null
}
export function getS3Client(): S3Client {
if (_s3Client) return _s3Client
const { region } = S3_CONFIG
if (!region) {
throw new Error(
'AWS region is missing set AWS_REGION in your environment or disable S3 uploads.'
)
}
_s3Client = new S3Client({
region,
endpoint: S3_CONFIG.endpoint,
forcePathStyle: S3_CONFIG.forcePathStyle,
credentials: getAwsCredentialsFromEnv(),
})
return _s3Client
}
/**
* Upload a file to S3
* @param file Buffer containing file data
* @param fileName Original file name
* @param contentType MIME type of the file
* @param configOrSize Custom S3 configuration OR file size in bytes (optional)
* @param size File size in bytes (required if configOrSize is S3Config, optional otherwise)
* @param skipTimestampPrefix Skip adding timestamp prefix to filename (default: false)
* @param metadata Optional metadata to store with the file
* @returns Object with file information
*/
export async function uploadToS3(
file: Buffer,
fileName: string,
contentType: string,
configOrSize?: S3Config | number,
size?: number,
skipTimestampPrefix?: boolean,
metadata?: Record<string, string>
): Promise<FileInfo> {
let config: S3Config
let fileSize: number
let shouldSkipTimestamp: boolean
if (typeof configOrSize === 'object') {
config = configOrSize
fileSize = size ?? file.length
shouldSkipTimestamp = skipTimestampPrefix ?? false
} else {
config = { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region }
fileSize = configOrSize ?? file.length
shouldSkipTimestamp = skipTimestampPrefix ?? false
}
const safeFileName = sanitizeFileName(fileName)
const uniqueKey = shouldSkipTimestamp ? fileName : `${Date.now()}-${safeFileName}`
const s3Client = getS3Client()
const s3Metadata: Record<string, string> = {
originalName: sanitizeFilenameForMetadata(fileName),
uploadedAt: new Date().toISOString(),
}
if (metadata) {
Object.assign(s3Metadata, sanitizeStorageMetadata(metadata, 2000))
}
await s3Client.send(
new PutObjectCommand({
Bucket: config.bucket,
Key: uniqueKey,
Body: file,
ContentType: contentType,
Metadata: s3Metadata,
})
)
const servePath = `/api/files/serve/${encodeURIComponent(uniqueKey)}`
return {
path: servePath,
key: uniqueKey,
name: fileName,
size: fileSize,
type: contentType,
}
}
/**
* Generate a presigned URL for direct file access
* @param key S3 object key
* @param expiresIn Time in seconds until URL expires
* @returns Presigned URL
*/
export async function getPresignedUrl(key: string, expiresIn = 3600) {
const command = new GetObjectCommand({
Bucket: S3_CONFIG.bucket,
Key: key,
})
return getSignedUrl(getS3Client(), command, { expiresIn })
}
/**
* Generate a presigned URL for direct file access with custom bucket
* @param key S3 object key
* @param customConfig Custom S3 configuration
* @param expiresIn Time in seconds until URL expires
* @returns Presigned URL
*/
export async function getPresignedUrlWithConfig(
key: string,
customConfig: S3Config,
expiresIn = 3600
) {
const command = new GetObjectCommand({
Bucket: customConfig.bucket,
Key: key,
})
return getSignedUrl(getS3Client(), command, { expiresIn })
}
/**
* Download a file from S3
* @param key S3 object key
* @returns File buffer
*/
export async function downloadFromS3(key: string): Promise<Buffer>
/**
* Download a file from S3 with custom bucket configuration
* @param key S3 object key
* @param customConfig Custom S3 configuration
* @returns File buffer
*/
export async function downloadFromS3(key: string, customConfig: S3Config): Promise<Buffer>
export async function downloadFromS3(
key: string,
customConfig: S3Config,
maxBytes: number
): Promise<Buffer>
export async function downloadFromS3(
key: string,
customConfig?: S3Config,
maxBytes?: number
): Promise<Buffer> {
const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region }
const command = new GetObjectCommand({
Bucket: config.bucket,
Key: key,
})
const response = await getS3Client().send(command)
if (maxBytes !== undefined && response.ContentLength !== undefined) {
try {
assertKnownSizeWithinLimit(response.ContentLength, maxBytes, 'storage download')
} catch (error) {
const body = response.Body as { destroy?: (error?: Error) => void } | undefined
body?.destroy?.(error instanceof Error ? error : undefined)
throw error
}
}
const stream = response.Body as NodeJS.ReadableStream
return readNodeStreamToBufferWithLimit(stream, {
maxBytes: maxBytes ?? Number.MAX_SAFE_INTEGER,
label: 'storage download',
})
}
/**
* Stream an object out of S3 without buffering it. The caller MUST fully consume or
* `destroy()` the returned stream. Used by the large-CSV import worker so a 1M-row file is
* never resident in memory.
*/
export async function downloadFromS3Stream(
key: string,
customConfig?: S3Config
): Promise<Readable> {
const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region }
const command = new GetObjectCommand({ Bucket: config.bucket, Key: key })
const response = await getS3Client().send(command)
if (!response.Body) {
throw new Error(`S3 object has no body: ${key}`)
}
return response.Body as Readable
}
/**
* Check whether an object exists in S3 (and return its size when it does).
* Returns null when the object is missing.
*/
export async function headS3Object(
key: string,
customConfig?: S3Config
): Promise<{ size: number; contentType?: string } | null> {
const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region }
try {
const response = await getS3Client().send(
new HeadObjectCommand({ Bucket: config.bucket, Key: key })
)
return {
size: response.ContentLength ?? 0,
contentType: response.ContentType,
}
} catch (error) {
const code = (error as { name?: string; $metadata?: { httpStatusCode?: number } } | null)?.name
const status = (error as { $metadata?: { httpStatusCode?: number } } | null)?.$metadata
?.httpStatusCode
if (code === 'NotFound' || code === 'NoSuchKey' || status === 404) {
return null
}
throw error
}
}
/**
* Delete a file from S3
* @param key S3 object key
*/
export async function deleteFromS3(key: string): Promise<void>
/**
* Delete a file from S3 with custom bucket configuration
* @param key S3 object key
* @param customConfig Custom S3 configuration
*/
export async function deleteFromS3(key: string, customConfig: S3Config): Promise<void>
export async function deleteFromS3(key: string, customConfig?: S3Config): Promise<void> {
const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region }
await getS3Client().send(
new DeleteObjectCommand({
Bucket: config.bucket,
Key: key,
})
)
}
/** S3 `DeleteObjects` hard cap. */
const S3_DELETE_OBJECTS_MAX_KEYS = 1000
/**
* Multi-object delete. One HTTP call per 1000 keys; each key still counts
* against the per-prefix DELETE rate limit (3500/sec).
*/
export async function deleteManyFromS3(
keys: string[],
customConfig?: S3Config
): Promise<{ failed: Array<{ key: string; error: string }> }> {
const failed: Array<{ key: string; error: string }> = []
if (keys.length === 0) return { failed }
const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region }
const s3Client = getS3Client()
for (let i = 0; i < keys.length; i += S3_DELETE_OBJECTS_MAX_KEYS) {
const chunk = keys.slice(i, i + S3_DELETE_OBJECTS_MAX_KEYS)
try {
const response = await s3Client.send(
new DeleteObjectsCommand({
Bucket: config.bucket,
Delete: {
Objects: chunk.map((Key) => ({ Key })),
Quiet: true,
},
})
)
for (const error of response.Errors ?? []) {
if (error.Key) {
failed.push({
key: error.Key,
error: error.Message ?? error.Code ?? 'unknown',
})
}
}
} catch (error) {
const message = getErrorMessage(error)
for (const Key of chunk) failed.push({ key: Key, error: message })
}
}
return { failed }
}
/**
* Initiate a multipart upload for S3
*/
export async function initiateS3MultipartUpload(
options: S3MultipartUploadInit
): Promise<{ uploadId: string; key: string }> {
const { fileName, contentType, customConfig, customKey, purpose } = options
const config = customConfig || { bucket: S3_KB_CONFIG.bucket, region: S3_KB_CONFIG.region }
const s3Client = getS3Client()
const safeFileName = sanitizeFileName(fileName)
const uniqueKey = customKey || `kb/${generateId()}-${safeFileName}`
const command = new CreateMultipartUploadCommand({
Bucket: config.bucket,
Key: uniqueKey,
ContentType: contentType,
Metadata: {
originalName: sanitizeFilenameForMetadata(fileName),
uploadedAt: new Date().toISOString(),
purpose: purpose || 'knowledge-base',
},
})
const response = await s3Client.send(command)
if (!response.UploadId) {
throw new Error('Failed to initiate S3 multipart upload')
}
return {
uploadId: response.UploadId,
key: uniqueKey,
}
}
/**
* Upload a single multipart part from the server (Body in hand), returning its
* `{ PartNumber, ETag }`. The presigned variant ({@link getS3MultipartPartUrls})
* is for browser uploads; this is the server-side streaming path.
*/
export async function uploadS3Part(
key: string,
uploadId: string,
partNumber: number,
body: Buffer,
customConfig?: S3Config
): Promise<S3MultipartPart> {
const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region }
const response = await getS3Client().send(
new UploadPartCommand({
Bucket: config.bucket,
Key: key,
PartNumber: partNumber,
UploadId: uploadId,
Body: body,
})
)
if (!response.ETag) {
throw new Error(`S3 UploadPart returned no ETag for part ${partNumber} of ${key}`)
}
return { PartNumber: partNumber, ETag: response.ETag }
}
/**
* Generate presigned URLs for uploading parts to S3
*/
export async function getS3MultipartPartUrls(
key: string,
uploadId: string,
partNumbers: number[],
customConfig?: S3Config
): Promise<S3PartUploadUrl[]> {
const config = customConfig || { bucket: S3_KB_CONFIG.bucket, region: S3_KB_CONFIG.region }
const s3Client = getS3Client()
const presignedUrls = await Promise.all(
partNumbers.map(async (partNumber) => {
const command = new UploadPartCommand({
Bucket: config.bucket,
Key: key,
PartNumber: partNumber,
UploadId: uploadId,
})
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 })
return { partNumber, url }
})
)
return presignedUrls
}
/**
* Build a fallback object URL for when the SDK omits `Location` on multipart
* completion. For a custom `S3_CONFIG.endpoint` it matches the configured
* addressing mode — path-style for MinIO/Ceph (`forcePathStyle`), virtual-hosted
* (bucket as a subdomain) for R2 and friends. Falls back to the AWS
* virtual-hosted host when no custom endpoint is set.
*
* The key is percent-encoded per path segment (preserving `/` separators) so
* keys containing spaces or reserved characters still yield a valid URL.
*/
function buildObjectFallbackUrl(bucket: string, region: string, key: string): string {
const encodedKey = key
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/')
if (S3_CONFIG.endpoint) {
const base = S3_CONFIG.endpoint.replace(/\/+$/, '')
if (S3_CONFIG.forcePathStyle) {
return `${base}/${bucket}/${encodedKey}`
}
const url = new URL(base)
url.hostname = `${bucket}.${url.hostname}`
return `${url.origin}/${encodedKey}`
}
return `https://${bucket}.s3.${region}.amazonaws.com/${encodedKey}`
}
/**
* Complete multipart upload for S3
*/
export async function completeS3MultipartUpload(
key: string,
uploadId: string,
parts: S3MultipartPart[],
customConfig?: S3Config
): Promise<{ location: string; path: string; key: string }> {
const config = customConfig || { bucket: S3_KB_CONFIG.bucket, region: S3_KB_CONFIG.region }
const s3Client = getS3Client()
const command = new CompleteMultipartUploadCommand({
Bucket: config.bucket,
Key: key,
UploadId: uploadId,
MultipartUpload: {
Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber),
},
})
const response = await s3Client.send(command)
const location = response.Location || buildObjectFallbackUrl(config.bucket, config.region, key)
const path = `/api/files/serve/${encodeURIComponent(key)}`
return {
location,
path,
key,
}
}
/**
* Abort multipart upload for S3
*/
export async function abortS3MultipartUpload(
key: string,
uploadId: string,
customConfig?: S3Config
): Promise<void> {
const config = customConfig || { bucket: S3_KB_CONFIG.bucket, region: S3_KB_CONFIG.region }
const s3Client = getS3Client()
const command = new AbortMultipartUploadCommand({
Bucket: config.bucket,
Key: key,
UploadId: uploadId,
})
await s3Client.send(command)
}
@@ -0,0 +1,31 @@
export interface S3Config {
bucket: string
region: string
}
export interface S3MultipartUploadInit {
fileName: string
contentType: string
fileSize: number
customConfig?: S3Config
/**
* When provided, overrides the default `kb/${id}-${name}` key derivation.
* Caller is responsible for uniqueness and prefix conventions.
*/
customKey?: string
/**
* Storage purpose tag persisted as object metadata. Defaults to `knowledge-base`
* for backwards compatibility.
*/
purpose?: string
}
export interface S3PartUploadUrl {
partNumber: number
url: string
}
export interface S3MultipartPart {
ETag: string
PartNumber: number
}
@@ -0,0 +1,63 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetWorkspaceFile, mockGetFileMetadataByKey } = vi.hoisted(() => ({
mockGetWorkspaceFile: vi.fn(),
mockGetFileMetadataByKey: vi.fn(),
}))
vi.mock('@/lib/uploads/contexts/workspace', () => ({ getWorkspaceFile: mockGetWorkspaceFile }))
vi.mock('@/lib/uploads/server/metadata', () => ({ getFileMetadataByKey: mockGetFileMetadataByKey }))
import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image'
describe('resolveWorkspaceInlineImage', () => {
beforeEach(() => vi.clearAllMocks())
it('resolves by fileId scoped to the workspace (getWorkspaceFile already enforces scope)', async () => {
mockGetWorkspaceFile.mockResolvedValue({
key: 'workspace/ws-1/x.png',
type: 'image/png',
name: 'x.png',
})
const out = await resolveWorkspaceInlineImage('ws-1', { fileId: 'wf_a' })
expect(mockGetWorkspaceFile).toHaveBeenCalledWith('ws-1', 'wf_a')
expect(out).toEqual({
key: 'workspace/ws-1/x.png',
contentType: 'image/png',
filename: 'x.png',
})
})
it('returns null when getWorkspaceFile finds nothing (cross-workspace / deleted / non-workspace)', async () => {
mockGetWorkspaceFile.mockResolvedValue(null)
expect(await resolveWorkspaceInlineImage('ws-1', { fileId: 'wf_a' })).toBeNull()
})
it('resolves by key only when the row belongs to the workspace', async () => {
mockGetFileMetadataByKey.mockResolvedValue({
key: 'workspace/ws-1/x.png',
workspaceId: 'ws-1',
contentType: 'image/png',
originalName: 'x.png',
})
const out = await resolveWorkspaceInlineImage('ws-1', { key: 'workspace/ws-1/x.png' })
expect(out).toEqual({
key: 'workspace/ws-1/x.png',
contentType: 'image/png',
filename: 'x.png',
})
})
it('returns null when the keyed row belongs to a different workspace', async () => {
mockGetFileMetadataByKey.mockResolvedValue({
key: 'workspace/ws-2/x.png',
workspaceId: 'ws-2',
contentType: 'image/png',
originalName: 'x.png',
})
expect(await resolveWorkspaceInlineImage('ws-1', { key: 'workspace/ws-2/x.png' })).toBeNull()
})
})
@@ -0,0 +1,41 @@
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { getFileMetadataByKey } from '@/lib/uploads/server/metadata'
/**
* A markdown-embedded image reference: either a workspace file `fileId` (view-URL embeds) or a
* workspace storage `key` (serve-URL embeds). Exactly one is set — enforced at the route boundary.
*/
export interface InlineImageRef {
key?: string
fileId?: string
}
/** The fields a serve handler needs to return an embedded image. */
export interface ResolvedInlineImage {
key: string
contentType: string
filename: string
}
/**
* Resolve an embedded-image reference to its storage key + metadata, **scoped to `workspaceId`**.
* Returns null whenever the reference is not a live `workspace` file in that workspace — a
* cross-workspace, non-workspace, missing, or deleted file. This is the single workspace-scope gate
* shared by the in-app inline route and the public-share cascade, mirroring how the user-facing file
* view resolves a file within its workspace ({@link getWorkspaceFile}).
*/
export async function resolveWorkspaceInlineImage(
workspaceId: string,
ref: InlineImageRef
): Promise<ResolvedInlineImage | null> {
if (ref.fileId) {
const file = await getWorkspaceFile(workspaceId, ref.fileId)
return file ? { key: file.key, contentType: file.type, filename: file.name } : null
}
if (ref.key) {
const record = await getFileMetadataByKey(ref.key, 'workspace')
if (!record || record.workspaceId !== workspaceId) return null
return { key: record.key, contentType: record.contentType, filename: record.originalName }
}
return null
}
+277
View File
@@ -0,0 +1,277 @@
import { db } from '@sim/db'
import { workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
import type { StorageContext } from '../shared/types'
const logger = createLogger('FileMetadata')
export type FileMetadataRecord = typeof workspaceFiles.$inferSelect
export interface FileMetadataInsertOptions {
key: string
userId: string
workspaceId?: string | null
context: StorageContext
originalName: string
contentType: string
size: number
folderId?: string | null
/** Optional — a UUID is generated when omitted. */
id?: string
}
/**
* Insert file metadata into workspaceFiles table
* Handles duplicate key errors gracefully by returning existing record
*/
export async function insertFileMetadata(
options: FileMetadataInsertOptions
): Promise<FileMetadataRecord> {
const { key, userId, workspaceId, context, originalName, contentType, size, folderId, id } =
options
const existingDeleted = await db
.select()
.from(workspaceFiles)
.where(eq(workspaceFiles.key, key))
.limit(1)
if (existingDeleted.length > 0 && existingDeleted[0].deletedAt) {
const [restored] = await db
.update(workspaceFiles)
.set({
userId,
workspaceId: workspaceId || null,
folderId: folderId ?? null,
context,
originalName,
displayName: originalName,
contentType,
size,
deletedAt: null,
uploadedAt: new Date(),
})
.where(eq(workspaceFiles.id, existingDeleted[0].id))
.returning()
if (restored) {
return restored
}
}
const existing = await db
.select()
.from(workspaceFiles)
.where(and(eq(workspaceFiles.key, key), isNull(workspaceFiles.deletedAt)))
.limit(1)
if (existing.length > 0) {
return existing[0]
}
const fileId = id || generateId()
try {
const [inserted] = await db
.insert(workspaceFiles)
.values({
id: fileId,
key,
userId,
workspaceId: workspaceId || null,
folderId: folderId ?? null,
context,
originalName,
displayName: originalName,
contentType,
size,
deletedAt: null,
uploadedAt: new Date(),
})
.returning()
return inserted
} catch (error) {
const code = (error as { code?: string } | null)?.code
if (code === '23505' || (error instanceof Error && error.message.includes('unique'))) {
const existingAfterError = await db
.select()
.from(workspaceFiles)
.where(and(eq(workspaceFiles.key, key), isNull(workspaceFiles.deletedAt)))
.limit(1)
if (existingAfterError.length > 0) {
return existingAfterError[0]
}
}
logger.error(`Failed to insert file metadata for key: ${key}`, error)
throw error
}
}
/**
* Bulk-insert file metadata rows in a single statement.
*
* Intended for batch upload flows that create many fresh keys at once (e.g. the
* presigned batch route), replacing a fan-out of individual `insertFileMetadata`
* calls. Uses `ON CONFLICT DO NOTHING` on the active-key unique index, so it is
* safe against a concurrent single insert and idempotent for already-present
* active keys. Unlike {@link insertFileMetadata} it does NOT restore
* soft-deleted rows — callers use this only for newly generated keys.
*/
export async function insertFileMetadataMany(
rows: Array<Omit<FileMetadataInsertOptions, 'id'> & { id?: string }>
): Promise<void> {
if (rows.length === 0) {
return
}
await db
.insert(workspaceFiles)
.values(
rows.map((row) => ({
id: row.id || generateId(),
key: row.key,
userId: row.userId,
workspaceId: row.workspaceId || null,
folderId: row.folderId ?? null,
context: row.context,
originalName: row.originalName,
displayName: row.originalName,
contentType: row.contentType,
size: row.size,
deletedAt: null,
uploadedAt: new Date(),
}))
)
.onConflictDoNothing()
}
/**
* Get file metadata by key with optional context filter
*/
export async function getFileMetadataByKey(
key: string,
context?: StorageContext,
options?: { includeDeleted?: boolean }
): Promise<FileMetadataRecord | null> {
const { includeDeleted = false } = options ?? {}
const conditions = [eq(workspaceFiles.key, key)]
if (context) {
conditions.push(eq(workspaceFiles.context, context))
}
if (!includeDeleted) {
conditions.push(isNull(workspaceFiles.deletedAt))
}
const [record] = await db
.select()
.from(workspaceFiles)
.where(conditions.length > 1 ? and(...conditions) : conditions[0])
// Prefer the active row when includeDeleted lets both an active and a
// soft-deleted row for the same key match.
.orderBy(sql`${workspaceFiles.deletedAt} IS NULL DESC`)
.limit(1)
return record ?? null
}
/**
* Get active (non-deleted) file metadata for multiple keys in a single query.
* Batches what would otherwise be N `getFileMetadataByKey` calls.
*/
export async function getFileMetadataByKeys(
keys: string[],
context: StorageContext,
executor: Pick<typeof db, 'select'> = db
): Promise<FileMetadataRecord[]> {
if (keys.length === 0) {
return []
}
return executor
.select()
.from(workspaceFiles)
.where(
and(
inArray(workspaceFiles.key, keys),
eq(workspaceFiles.context, context),
isNull(workspaceFiles.deletedAt)
)
)
}
/**
* Get file metadata by ID
*/
export async function getFileMetadataById(
id: string,
options?: { includeDeleted?: boolean }
): Promise<FileMetadataRecord | null> {
const { includeDeleted = false } = options ?? {}
const conditions = [eq(workspaceFiles.id, id)]
if (!includeDeleted) conditions.push(isNull(workspaceFiles.deletedAt))
const [record] = await db
.select()
.from(workspaceFiles)
.where(conditions.length > 1 ? and(...conditions) : conditions[0])
.limit(1)
return record ?? null
}
/**
* Delete file metadata by key
*/
export async function deleteFileMetadata(key: string): Promise<boolean> {
await db
.update(workspaceFiles)
.set({ deletedAt: new Date() })
.where(and(eq(workspaceFiles.key, key), isNull(workspaceFiles.deletedAt)))
return true
}
/**
* Fields needed to record a trusted storage-key -> workspace ownership binding
* for a knowledge-base file. The `context` is always `'knowledge-base'`, so it is
* not part of this shape.
*/
export interface KnowledgeBaseFileOwnership {
key: string
userId: string
workspaceId: string
originalName: string
contentType: string
size: number
}
/**
* Record the ownership binding for a single knowledge-base upload. KB file
* authorization (`verifyKBFileAccess`) resolves the owning workspace from this
* binding, so every KB object must have exactly one. Single source of truth for
* the binding shape across the presigned, batch-presigned, and multipart upload
* paths — keep all callers routed through here so they cannot drift.
*/
export async function recordKnowledgeBaseFileOwnership(
ownership: KnowledgeBaseFileOwnership
): Promise<void> {
await insertFileMetadata({ ...ownership, context: 'knowledge-base' })
}
/**
* Bulk variant of {@link recordKnowledgeBaseFileOwnership} for batch upload flows.
* Idempotent against the active-key unique index (ON CONFLICT DO NOTHING).
*/
export async function recordKnowledgeBaseFileOwnershipMany(
ownerships: KnowledgeBaseFileOwnership[]
): Promise<void> {
if (ownerships.length === 0) {
return
}
await insertFileMetadataMany(
ownerships.map((ownership) => ({ ...ownership, context: 'knowledge-base' }))
)
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Defense-in-depth ceiling on the size of any single workspace file upload.
* Enforced both server-side (presigned route) and client-side (Files tab) so
* users get fast feedback before bytes are streamed.
*/
export const MAX_WORKSPACE_FILE_SIZE = 5 * 1024 * 1024 * 1024
/**
* Cap on the legacy FormData upload route, which buffers the whole file in
* worker memory. Direct-to-storage uploads use {@link MAX_WORKSPACE_FILE_SIZE}.
*/
export const MAX_WORKSPACE_FORMDATA_FILE_SIZE = 100 * 1024 * 1024
export type StorageContext =
| 'knowledge-base'
| 'chat'
| 'copilot'
| 'mothership'
| 'execution'
| 'workspace'
| 'profile-pictures'
| 'og-images'
| 'logs'
| 'workspace-logos'
/**
* Contexts exempt from storage quota checks. Includes system-internal contexts
* (`logs` — written by the execution pipeline, not user-initiated) and small
* metadata assets (`profile-pictures`, `workspace-logos`, `og-images`). All
* other contexts are user-driven uploads and must pass quota validation.
*
* Note: every quota-exempt context is excluded from `ALLOWED_UPLOAD_CONTEXTS`
* in the multipart endpoint, so none are reachable there — the exemption applies
* only to the single-part upload paths (presigned/FormData) those small assets
* actually use. The multipart endpoint therefore only ever serves
* quota-enforced contexts.
*/
export const QUOTA_EXEMPT_STORAGE_CONTEXTS = new Set<StorageContext>([
'profile-pictures',
'workspace-logos',
'og-images',
'logs',
])
export interface FileInfo {
path: string
key: string
name: string
size: number
type: string
}
export interface StorageConfig {
bucket?: string
region?: string
containerName?: string
accountName?: string
accountKey?: string
connectionString?: string
}
export interface UploadFileOptions {
file: Buffer
fileName: string
contentType: string
context: StorageContext
preserveKey?: boolean
customKey?: string
metadata?: Record<string, string>
}
export interface DownloadFileOptions {
key: string
context?: StorageContext
maxBytes?: number
}
export interface DeleteFileOptions {
key: string
context?: StorageContext
}
export interface GeneratePresignedUrlOptions {
fileName: string
contentType: string
fileSize: number
context: StorageContext
userId?: string
expirationSeconds?: number
metadata?: Record<string, string>
/**
* When provided, overrides the default `${context}/${timestamp}-${id}-${name}` key derivation.
* The caller takes responsibility for uniqueness and prefix conventions.
*/
customKey?: string
}
export interface PresignedUrlResponse {
url: string
key: string
uploadHeaders?: Record<string, string>
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import {
extractEmbeddedFileRef,
extractEmbeddedFileRefs,
} from '@/lib/uploads/utils/embedded-image-ref'
const KEY = 'workspace/W1/1700000000000-deadbeefdeadbeef-photo.png'
const ENCODED = encodeURIComponent(KEY)
describe('extractEmbeddedFileRef', () => {
it('parses serve-url embeds (encoded, raw, and s3/blob prefixed) to the workspace key', () => {
expect(extractEmbeddedFileRef(`/api/files/serve/${ENCODED}?context=workspace`)).toEqual({
key: KEY,
})
expect(extractEmbeddedFileRef(`/api/files/serve/s3/${ENCODED}`)).toEqual({ key: KEY })
expect(extractEmbeddedFileRef(`/api/files/serve/blob/${ENCODED}`)).toEqual({ key: KEY })
})
it('parses view-url and in-app-path embeds to the file id', () => {
expect(extractEmbeddedFileRef('/api/files/view/wf_YwDXi8eWOkTxn0sbgChlB')).toEqual({
fileId: 'wf_YwDXi8eWOkTxn0sbgChlB',
})
expect(extractEmbeddedFileRef('/workspace/W1/files/wf_abc')).toEqual({ fileId: 'wf_abc' })
})
it('returns null for external, data, and non-workspace serve urls', () => {
expect(extractEmbeddedFileRef('https://cdn.example.com/a.png')).toBeNull()
expect(extractEmbeddedFileRef('data:image/png;base64,AAAA')).toBeNull()
expect(extractEmbeddedFileRef('/api/files/serve/profile-pictures%2Fu1%2Favatar.png')).toBeNull()
})
})
describe('extractEmbeddedFileRefs', () => {
it('collects de-duplicated keys and ids from a document via the shared parser', () => {
const content = `
![a](/api/files/serve/${ENCODED}?context=workspace)
![b](/api/files/view/wf_abc)
![c](/workspace/W1/files/4bdaf6c4-072e-464e-891d-b6af3b5fe2cc)
![dup](/api/files/serve/s3/${ENCODED})
![ext](https://cdn.example.com/x.png)
![pub](/api/files/serve/profile-pictures%2Fu1%2Favatar.png)
`
const { keys, ids } = extractEmbeddedFileRefs(content)
expect(keys).toEqual([KEY])
expect(ids.sort()).toEqual(['4bdaf6c4-072e-464e-891d-b6af3b5fe2cc', 'wf_abc'].sort())
})
it('caps total references (keys + ids) at 50 combined', () => {
const ids = Array.from(
{ length: 40 },
(_, i) => `/api/files/view/wf_${String(i).padStart(6, '0')}`
)
const keys = Array.from(
{ length: 40 },
(_, i) => `/api/files/serve/${encodeURIComponent(`workspace/W1/k${i}.png`)}`
)
const { keys: k, ids: d } = extractEmbeddedFileRefs([...ids, ...keys].join(' '))
expect(k.length + d.length).toBe(50)
})
})
@@ -0,0 +1,73 @@
/**
* The grammar of a markdown-embedded workspace image reference, shared by the frontend renderer
* (which rewrites one `src` at a time) and the server (which scans a whole document for the
* referenced-by-doc gate and the export bundler). Both go through {@link extractEmbeddedFileRef} so
* the set the client links and the set the server authorizes can never drift apart.
*
* Pure and isomorphic — no DOM, Node, or DB imports — so it is safe to import from both client and
* server code.
*/
/** A reference parsed from an embed `src`: a workspace storage key, a workspace file id, or neither. */
export type EmbeddedFileRef = { key: string } | { fileId: string } | null
/** Hard cap on embedded images resolved from one document — bounds export bundles and the cascade. */
export const MAX_EMBEDDED_IMAGES = 50
/**
* Candidate embed URL substrings in document text: a serve URL, a view URL, or the in-app workspace
* path. The captured run stops at whitespace/quote/paren/angle/query so authoritative parsing is left
* to {@link extractEmbeddedFileRef}.
*/
const EMBED_URL_RE =
/(?:\/api\/files\/(?:serve|view)\/|\/workspace\/[A-Za-z0-9-]+\/files\/)[^\s)"'<>?]*/g
/**
* Parse a single embed `src` into the workspace file it references, normalizing the spellings the
* editor and file agent produce: `/api/files/serve/<key>` (incl. `s3/`/`blob/` prefixes), `/api/files/view/<id>`,
* and the in-app path `/workspace/<wsId>/files/<id>`. Returns null for absolute, `data:`, or non-workspace
* URLs (e.g. public `profile-pictures/` assets), which render as-is.
*/
export function extractEmbeddedFileRef(src: string): EmbeddedFileRef {
try {
const parsed = new URL(src, 'http://placeholder')
if (parsed.origin !== 'http://placeholder') return null
const segs = parsed.pathname.split('/')
if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'serve') {
let keySegs = segs.slice(4)
if (keySegs[0] === 's3' || keySegs[0] === 'blob') keySegs = keySegs.slice(1)
const raw = keySegs.join('/')
if (!raw) return null
const key = decodeURIComponent(raw)
return key.startsWith('workspace/') ? { key } : null
}
if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'view' && segs[4]) {
return { fileId: segs[4] }
}
if (segs[1] === 'workspace' && segs[3] === 'files' && segs[4]) {
return { fileId: segs[4] }
}
return null
} catch {
return null
}
}
/**
* The de-duplicated keys and ids embedded in `content`, bounded to {@link MAX_EMBEDDED_IMAGES} unique
* references **combined** (keys + ids). Every candidate URL is interpreted by {@link extractEmbeddedFileRef},
* so this is exactly the set the frontend rewrites — the server's referenced-by-doc gate and the export
* bundler share one grammar.
*/
export function extractEmbeddedFileRefs(content: string): { keys: string[]; ids: string[] } {
const keys = new Set<string>()
const ids = new Set<string>()
for (const match of content.matchAll(EMBED_URL_RE)) {
const ref = extractEmbeddedFileRef(match[0])
if (!ref) continue
if ('key' in ref) keys.add(ref.key)
else ids.add(ref.fileId)
if (keys.size + ids.size >= MAX_EMBEDDED_IMAGES) break
}
return { keys: [...keys], ids: [...ids] }
}
@@ -0,0 +1,54 @@
import { z } from 'zod'
import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
const isUrlLike = (value: string) =>
value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/')
export const RawFileInputSchema = z
.object({
id: z.string().optional(),
key: z.string().optional(),
path: z.string().optional(),
url: z.string().optional(),
name: z.string().min(1),
size: z.number().nonnegative(),
type: z.string().optional(),
uploadedAt: z.union([z.string(), z.date()]).optional(),
expiresAt: z.union([z.string(), z.date()]).optional(),
context: z.string().optional(),
base64: z.string().optional(),
})
.passthrough()
.refine((data) => Boolean(data.key || data.path || data.url), {
message: 'File must include key, path, or url',
})
.refine(
(data) => {
if (data.key || data.path) {
return true
}
if (!data.url) {
return true
}
return isInternalFileUrl(data.url)
},
{ message: 'File url must reference an uploaded file' }
)
.refine(
(data) => {
if (data.key || !data.path) {
return true
}
if (!isUrlLike(data.path)) {
return true
}
return isInternalFileUrl(data.path)
},
{ message: 'File path must reference an uploaded file' }
)
export type RawFileInput = z.infer<typeof RawFileInputSchema>
export const RawFileInputArraySchema = z.array(RawFileInputSchema)
export const FileInputSchema = z.union([RawFileInputSchema, z.string()])
@@ -0,0 +1,47 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDownloadFile } = vi.hoisted(() => ({
mockDownloadFile: vi.fn(),
}))
vi.mock('@/lib/uploads/core/storage-service', () => ({
downloadFile: mockDownloadFile,
hasCloudStorage: vi.fn(() => true),
}))
vi.mock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn(),
}))
import { createLogger } from '@sim/logger'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import type { UserFile } from '@/executor/types'
describe('downloadFileFromStorage context derivation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDownloadFile.mockResolvedValue(Buffer.from('bytes'))
})
it('downloads with the key-derived context, ignoring a caller-supplied public context', async () => {
const userFile: UserFile = {
id: 'f1',
name: 'report.pdf',
url: '',
size: 5,
type: 'application/pdf',
key: 'workspace/ws-1/1700000000000-abc1234-report.pdf',
context: 'og-images',
}
await downloadFileFromStorage(userFile, 'req-1', createLogger('test'))
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
expect(mockDownloadFile).toHaveBeenCalledWith(
expect.objectContaining({ key: userFile.key, context: 'workspace' })
)
})
})
@@ -0,0 +1,367 @@
'use server'
import { createLogger, type Logger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import {
assertKnownSizeWithinLimit,
consumeOrCancelBody,
readResponseToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
import { StorageService } from '@/lib/uploads'
import { isExecutionFile } from '@/lib/uploads/contexts/execution/utils'
import {
extractStorageKey,
getFileExtension,
getMimeTypeFromExtension,
inferContextFromKey,
isInternalFileUrl,
processSingleFileToUserFile,
type RawFileInput,
resolveTrustedFileContext,
} from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
import type { UserFile } from '@/executor/types'
const logger = createLogger('FileUtilsServer')
/**
* Result type for file input resolution
*/
export interface FileResolutionResult {
fileUrl?: string
error?: {
status: number
message: string
}
}
/**
* Options for resolving file input to a URL
*/
export interface ResolveFileInputOptions {
file?: RawFileInput
filePath?: string
userId: string
requestId: string
logger: Logger
}
/**
* Resolves file input (either a file object or filePath string) to a publicly accessible URL.
* Handles:
* - Processing raw file input via processSingleFileToUserFile
* - Resolving internal URLs via resolveInternalFileUrl
* - Generating presigned URLs for storage keys
* - Validating external URLs via validateUrlWithDNS
*/
export async function resolveFileInputToUrl(
options: ResolveFileInputOptions
): Promise<FileResolutionResult> {
const { file, filePath, userId, requestId, logger } = options
if (file) {
let userFile: UserFile
try {
userFile = processSingleFileToUserFile(file, requestId, logger)
} catch (error) {
return {
error: {
status: 400,
message: getErrorMessage(error, 'Failed to process file'),
},
}
}
let fileUrl = userFile.url || ''
// Handle internal URLs
if (fileUrl && isInternalFileUrl(fileUrl)) {
const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger)
if (resolution.error) {
return { error: resolution.error }
}
fileUrl = resolution.fileUrl || ''
}
// Generate presigned URL if we have a key but no URL
if (!fileUrl && userFile.key) {
const context = resolveTrustedFileContext(userFile.key, userFile.context)
const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false)
if (!hasAccess) {
logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, {
userId,
key: userFile.key,
context,
})
return { error: { status: 404, message: 'File not found' } }
}
fileUrl = await StorageService.generatePresignedDownloadUrl(userFile.key, context, 5 * 60)
}
return { fileUrl }
}
if (filePath) {
let fileUrl = filePath
if (isInternalFileUrl(filePath)) {
const resolution = await resolveInternalFileUrl(filePath, userId, requestId, logger)
if (resolution.error) {
return { error: resolution.error }
}
fileUrl = resolution.fileUrl || fileUrl
} else if (filePath.startsWith('/')) {
logger.warn(`[${requestId}] Invalid internal path`, {
userId,
path: filePath.substring(0, 50),
})
return {
error: {
status: 400,
message: 'Invalid file path. Only uploaded files are supported for internal paths.',
},
}
} else {
const urlValidation = await validateUrlWithDNS(fileUrl, 'filePath')
if (!urlValidation.isValid) {
return { error: { status: 400, message: urlValidation.error || 'Invalid URL' } }
}
}
return { fileUrl }
}
return { error: { status: 400, message: 'File input is required' } }
}
/**
* Options for {@link downloadFileFromUrl}.
*/
export interface DownloadFileFromUrlOptions {
/** Download timeout for external URLs. Defaults to the max execution timeout. */
timeoutMs?: number
/** Hard cap on the number of bytes read from the source. */
maxBytes?: number
/**
* Principal the download is performed on behalf of. Required to authorize
* internal (`/api/files/serve/...`) URLs: the resolved storage key is checked
* with {@link verifyFileAccess} before any bytes are read. Without it, internal
* URLs are rejected (fail closed) so a `/api/files/serve/` substring can never
* be treated as implicitly trusted.
*/
userId?: string
}
/**
* Download a file from a URL (internal or external).
*
* For internal URLs, uses direct storage access (server-side only) after
* authorizing the resolved storage key against `userId`. Context is derived
* from the key via {@link inferContextFromKey}, never from a caller-controlled
* `?context=` query param — trusting the param would let a private key be
* labeled with a world-readable context (e.g. profile-pictures) so
* {@link verifyFileAccess} short-circuits to granted while the private object is
* still read. This mirrors how `/api/files/serve` resolves context.
*
* For external URLs, validates DNS/SSRF and uses secure fetch with IP pinning.
*/
export async function downloadFileFromUrl(
fileUrl: string,
options: DownloadFileFromUrlOptions = {}
): Promise<Buffer> {
const { timeoutMs = getMaxExecutionTimeout(), maxBytes, userId } = options
if (isInternalFileUrl(fileUrl)) {
if (!userId) {
logger.warn('Internal file download denied: no userId provided', { fileUrl })
throw new Error('Access denied: internal file URL requires an authenticated user')
}
const key = extractStorageKey(fileUrl)
if (!key) {
logger.warn('Internal file download denied: could not resolve storage key', { fileUrl })
throw new Error('Access denied: could not resolve internal file key')
}
const context = inferContextFromKey(key)
const hasAccess = await verifyFileAccess(key, userId, undefined, context, false)
if (!hasAccess) {
logger.warn('Internal file download denied: access check failed', { key, context, userId })
throw new Error('Access denied: file not found or insufficient permissions')
}
const { downloadFile } = await import('@/lib/uploads/core/storage-service')
return downloadFile({ key, context, maxBytes })
}
const urlValidation = await validateUrlWithDNS(fileUrl, 'fileUrl')
if (!urlValidation.isValid) {
throw new Error(`Invalid file URL: ${urlValidation.error}`)
}
const response = await secureFetchWithPinnedIP(fileUrl, urlValidation.resolvedIP!, {
timeout: timeoutMs,
maxResponseBytes: maxBytes,
})
if (!response.ok) {
await consumeOrCancelBody(response)
throw new Error(`Failed to download file: ${response.statusText}`)
}
return readResponseToBufferWithLimit(response, {
maxBytes: maxBytes ?? Number.MAX_SAFE_INTEGER,
label: 'file download',
})
}
export async function resolveInternalFileUrl(
filePath: string,
userId: string,
requestId: string,
logger: Logger
): Promise<{ fileUrl?: string; error?: { status: number; message: string } }> {
if (!isInternalFileUrl(filePath)) {
return { fileUrl: filePath }
}
try {
const storageKey = extractStorageKey(filePath)
const context = inferContextFromKey(storageKey)
const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false)
if (!hasAccess) {
logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, {
userId,
key: storageKey,
context,
})
return { error: { status: 404, message: 'File not found' } }
}
const fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
return { fileUrl }
} catch (error) {
logger.error(`[${requestId}] Failed to generate presigned URL:`, error)
return { error: { status: 500, message: 'Failed to generate file access URL' } }
}
}
/**
* Downloads a file from storage (execution or regular)
* @param userFile - UserFile object
* @param requestId - Request ID for logging
* @param logger - Logger instance
* @returns Buffer containing file data
*/
export async function downloadFileFromStorage(
userFile: UserFile,
requestId: string,
logger: Logger,
options: { maxBytes?: number } = {}
): Promise<Buffer> {
let buffer: Buffer
if (options.maxBytes !== undefined && userFile.size > options.maxBytes) {
assertKnownSizeWithinLimit(userFile.size, options.maxBytes, 'storage file download')
}
if (isExecutionFile(userFile)) {
logger.info(`[${requestId}] Downloading from execution storage: ${userFile.key}`)
const { downloadExecutionFile } = await import(
'@/lib/uploads/contexts/execution/execution-file-manager'
)
buffer = await downloadExecutionFile(userFile, { maxBytes: options.maxBytes })
} else if (userFile.key) {
const context = resolveTrustedFileContext(userFile.key, userFile.context)
logger.info(`[${requestId}] Downloading from ${context} storage: ${userFile.key}`)
const { downloadFile } = await import('@/lib/uploads/core/storage-service')
buffer = await downloadFile({
key: userFile.key,
context,
maxBytes: options.maxBytes,
})
} else {
throw new Error('File has no key - cannot download')
}
if (options.maxBytes !== undefined) {
assertKnownSizeWithinLimit(buffer.length, options.maxBytes, 'storage file download')
}
return buffer
}
/**
* Result of {@link downloadServableFileFromStorage}: the bytes a consumer should
* actually attach/upload, plus the content type that matches those bytes.
*/
export interface ServableFile {
buffer: Buffer
contentType: string
}
/**
* Downloads a workspace file and resolves it to its SERVABLE bytes — the variant
* every tool that hands a file to an external service (email attachments, chat
* uploads, provider file inputs) should use instead of {@link downloadFileFromStorage}.
*
* AI-generated docs (pdf/docx/pptx/xlsx) store their generation SOURCE as the
* primary file and keep the rendered binary in a separate content-addressed
* artifact store. A raw download therefore yields source text under a `.pdf`
* name — the file the recipient cannot open. This swaps in the compiled artifact
* (and the correct binary content type) via the same resolver the file-serve
* route uses, so the serve and attachment paths resolve identically. Non-doc files
* and real uploaded binaries pass through unchanged, carrying `userFile.type` when set.
*
* Throws `DocCompileUserError` when a generated doc's artifact is not ready (still
* compiling) — callers should surface a retryable error rather than attach source.
*/
export async function downloadServableFileFromStorage(
userFile: UserFile,
requestId: string,
logger: Logger,
options: { maxBytes?: number; signal?: AbortSignal; ownerKey?: string } = {}
): Promise<ServableFile> {
const buffer = await downloadFileFromStorage(userFile, requestId, logger, {
maxBytes: options.maxBytes,
})
// Cheap pre-filter so only generated-doc candidates pay for the heavier resolver
// import below.
const ext = getFileExtension(userFile.name)
if (ext !== 'pdf' && ext !== 'docx' && ext !== 'pptx' && ext !== 'xlsx') {
return { buffer, contentType: userFile.type || getMimeTypeFromExtension(ext) }
}
const { parseWorkspaceFileKey } = await import(
'@/lib/uploads/contexts/workspace/workspace-file-manager'
)
const workspaceId = userFile.key ? (parseWorkspaceFileKey(userFile.key) ?? undefined) : undefined
const { resolveServableDocBytes } = await import('@/lib/copilot/tools/server/files/doc-compile')
const resolved = await resolveServableDocBytes({
rawBuffer: buffer,
fileName: userFile.name,
workspaceId,
ownerKey: options.ownerKey,
signal: options.signal,
})
// Re-check: the raw download enforced maxBytes on the source, but a generated doc
// resolves to a larger artifact.
if (options.maxBytes !== undefined && resolved.buffer.length > options.maxBytes) {
assertKnownSizeWithinLimit(resolved.buffer.length, options.maxBytes, 'servable file download')
}
return resolved
}
@@ -0,0 +1,155 @@
/**
* @vitest-environment node
*/
import { createLogger } from '@sim/logger'
import { describe, expect, it } from 'vitest'
import {
inferContextFromKey,
isAbortError,
isInternalFileUrl,
isNetworkError,
processSingleFileToUserFile,
resolveTrustedFileContext,
} from '@/lib/uploads/utils/file-utils'
const logger = createLogger('FileUtilsTest')
describe('isInternalFileUrl', () => {
it('classifies relative serve paths as internal', () => {
expect(isInternalFileUrl('/api/files/serve/kb/123-file.pdf')).toBe(true)
expect(isInternalFileUrl('/api/files/serve/workspace/ws-1/file.txt?context=workspace')).toBe(
true
)
})
it('classifies absolute serve URLs as internal regardless of host', () => {
expect(isInternalFileUrl('https://www.sim.ai/api/files/serve/kb/x.pdf')).toBe(true)
expect(isInternalFileUrl('http://localhost:3000/api/files/serve/blob/kb/x')).toBe(true)
// Host is not used to gate (self-hosted/multi-domain); the storage sink authorizes.
expect(isInternalFileUrl('https://other-host/api/files/serve/workspace/v/x')).toBe(true)
})
it('does not match the marker outside the path (query/fragment)', () => {
expect(isInternalFileUrl('https://evil.com/x?next=/api/files/serve/secret')).toBe(false)
expect(isInternalFileUrl('https://evil.com/page#/api/files/serve/secret')).toBe(false)
expect(isInternalFileUrl('https://evil.com/redirect?u=/api/files/serve/kb/x')).toBe(false)
})
it('preserves traversal sequences so they survive downstream rejection', () => {
// Must stay internal (not normalized away) so the parse route applies its `..` check.
expect(isInternalFileUrl('https://attacker.com/api/files/serve/../../../etc/passwd')).toBe(true)
expect(isInternalFileUrl('/api/files/serve/../../app.js')).toBe(true)
})
it('returns false for non-internal and non-string inputs', () => {
expect(isInternalFileUrl('https://example.com/file.pdf')).toBe(false)
expect(isInternalFileUrl('data:text/plain;base64,abc')).toBe(false)
// @ts-expect-error verifying runtime guard
expect(isInternalFileUrl(undefined)).toBe(false)
})
})
describe('inferContextFromKey', () => {
it('maps both kb/ and knowledge-base/ prefixes to knowledge-base', () => {
expect(inferContextFromKey('kb/1700000000000-doc.pdf')).toBe('knowledge-base')
// Direct/presigned uploads key as `${context}/...`, i.e. `knowledge-base/...`
expect(inferContextFromKey('knowledge-base/1781612506186-b2442e0dc045cb6c-doc.txt')).toBe(
'knowledge-base'
)
})
it('maps the remaining context prefixes', () => {
expect(inferContextFromKey('chat/x')).toBe('chat')
expect(inferContextFromKey('copilot/x')).toBe('copilot')
expect(inferContextFromKey('execution/ws/wf/ex/x')).toBe('execution')
expect(inferContextFromKey('workspace/ws/x')).toBe('workspace')
expect(inferContextFromKey('profile-pictures/x')).toBe('profile-pictures')
expect(inferContextFromKey('og-images/x')).toBe('og-images')
expect(inferContextFromKey('workspace-logos/x')).toBe('workspace-logos')
expect(inferContextFromKey('logs/x')).toBe('logs')
})
it('throws for empty or unrecognized keys', () => {
expect(() => inferContextFromKey('')).toThrow()
expect(() => inferContextFromKey('mystery/x')).toThrow()
})
})
describe('resolveTrustedFileContext', () => {
it('derives from the key prefix and ignores a mismatched caller context', () => {
expect(resolveTrustedFileContext('workspace/ws/1700000000000-abc-x.pdf', 'og-images')).toBe(
'workspace'
)
expect(resolveTrustedFileContext('chat/x', 'workspace-logos')).toBe('chat')
expect(resolveTrustedFileContext('workspace/ws/x', 'mothership')).toBe('workspace')
})
it('honors the caller context for legacy keys with no inferrable prefix', () => {
expect(resolveTrustedFileContext('legacy/ws/wf/ex/report.pdf', 'execution')).toBe('execution')
})
it('never resolves an un-inferrable key to a world-readable context', () => {
expect(() => resolveTrustedFileContext('legacy/report.pdf', 'og-images')).toThrow()
expect(() => resolveTrustedFileContext('legacy/report.pdf', 'profile-pictures')).toThrow()
expect(() => resolveTrustedFileContext('legacy/report.pdf')).toThrow()
})
})
describe('isAbortError', () => {
it('returns true for AbortError-named errors', () => {
const err = new Error('aborted')
err.name = 'AbortError'
expect(isAbortError(err)).toBe(true)
})
it('returns false for generic Errors', () => {
expect(isAbortError(new Error('boom'))).toBe(false)
expect(isAbortError(null)).toBe(false)
expect(isAbortError('AbortError')).toBe(false)
})
})
describe('isNetworkError', () => {
it.each([
'fetch failed',
'Network request failed',
'connection reset',
'request timeout',
'operation timed out',
'ECONNRESET while reading body',
])('matches transient message %s', (msg) => {
expect(isNetworkError(new Error(msg))).toBe(true)
})
it('does not match deterministic errors', () => {
expect(isNetworkError(new Error('Forbidden'))).toBe(false)
expect(isNetworkError(new Error('Validation failed: name is required'))).toBe(false)
expect(isNetworkError('not an error')).toBe(false)
expect(isNetworkError(null)).toBe(false)
})
})
describe('processSingleFileToUserFile', () => {
it('strips server-only provider file handles from untrusted input', () => {
const result = processSingleFileToUserFile(
{
id: 'file-1',
name: 'doc.pdf',
url: '/api/files/serve/workspace%2Fws-1%2Fdoc.pdf?context=workspace',
size: 1024,
type: 'application/pdf',
key: 'workspace/ws-1/doc.pdf',
providerFileId: 'file-injected',
providerFileUri: 'https://injected/uri',
remoteUrl: 'http://169.254.169.254/latest/meta-data',
} as never,
'req-1',
logger
)
expect(result.providerFileId).toBeUndefined()
expect(result.providerFileUri).toBeUndefined()
expect(result.remoteUrl).toBeUndefined()
expect(result.key).toBe('workspace/ws-1/doc.pdf')
})
})
+969
View File
@@ -0,0 +1,969 @@
import type { Logger } from '@sim/logger'
import { omit } from '@sim/utils/object'
import type { StorageContext } from '@/lib/uploads'
import { ACCEPTED_FILE_TYPES, SUPPORTED_DOCUMENT_EXTENSIONS } from '@/lib/uploads/utils/validation'
import { isUuid } from '@/executor/constants'
import type { UserFile } from '@/executor/types'
interface FileAttachment {
id: string
key: string
filename: string
media_type: string
size: number
}
export interface MessageContent {
type: 'text' | 'image' | 'document' | 'audio' | 'video'
text?: string
source?: {
type: 'base64'
media_type: string
data: string
}
}
/**
* Mapping of MIME types to content types
*/
export const MIME_TYPE_MAPPING: Record<string, 'image' | 'document' | 'audio' | 'video'> = {
// Images
'image/jpeg': 'image',
'image/jpg': 'image',
'image/png': 'image',
'image/gif': 'image',
'image/webp': 'image',
'image/svg+xml': 'image', // SVG upload is allowed; createFileContent handles it separately for Claude API
'image/bmp': 'image',
'image/tiff': 'image',
'image/heic': 'image',
'image/heif': 'image',
'image/avif': 'image',
'image/x-icon': 'image',
'image/vnd.microsoft.icon': 'image',
// Documents
'application/pdf': 'document',
'text/plain': 'document',
'text/csv': 'document',
'application/json': 'document',
'application/xml': 'document',
'text/xml': 'document',
'text/html': 'document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document', // .docx
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document', // .xlsx
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'document', // .pptx
'application/msword': 'document', // .doc
'application/vnd.ms-excel': 'document', // .xls
'application/vnd.ms-powerpoint': 'document', // .ppt
'text/markdown': 'document',
'application/rtf': 'document',
// Audio
'audio/mpeg': 'audio', // .mp3
'audio/mp3': 'audio',
'audio/mp4': 'audio', // .m4a
'audio/x-m4a': 'audio',
'audio/m4a': 'audio',
'audio/wav': 'audio',
'audio/wave': 'audio',
'audio/x-wav': 'audio',
'audio/webm': 'audio',
'audio/ogg': 'audio',
'audio/vorbis': 'audio',
'audio/flac': 'audio',
'audio/x-flac': 'audio',
'audio/aac': 'audio',
'audio/x-aac': 'audio',
'audio/opus': 'audio',
// Video
'video/mp4': 'video',
'video/mpeg': 'video',
'video/quicktime': 'video', // .mov
'video/x-quicktime': 'video',
'video/x-msvideo': 'video', // .avi
'video/avi': 'video',
'video/x-matroska': 'video', // .mkv
'video/webm': 'video',
}
/**
* Get the content type for a given MIME type
*/
export function getContentType(mimeType: string): 'image' | 'document' | 'audio' | 'video' | null {
return MIME_TYPE_MAPPING[mimeType.toLowerCase()] || null
}
/**
* Check if a MIME type is supported
*/
export function isSupportedFileType(mimeType: string): boolean {
return mimeType.toLowerCase() in MIME_TYPE_MAPPING
}
/**
* Check if a MIME type is an image type (for copilot uploads)
*/
const IMAGE_MIME_TYPES = new Set(
Object.entries(MIME_TYPE_MAPPING)
.filter(([, v]) => v === 'image')
.map(([k]) => k)
)
export function isImageFileType(mimeType: string): boolean {
return IMAGE_MIME_TYPES.has(mimeType.toLowerCase())
}
/**
* Check if a MIME type is an audio type
*/
export function isAudioFileType(mimeType: string): boolean {
return getContentType(mimeType) === 'audio'
}
/**
* Check if a MIME type is a video type
*/
export function isVideoFileType(mimeType: string): boolean {
return getContentType(mimeType) === 'video'
}
/**
* Check if a MIME type is an audio or video type
*/
export function isMediaFileType(mimeType: string): boolean {
const contentType = getContentType(mimeType)
return contentType === 'audio' || contentType === 'video'
}
/**
* Convert a file buffer to base64
*/
export function bufferToBase64(buffer: Buffer): string {
return buffer.toString('base64')
}
/**
* Create message content from file data
*/
export function createFileContent(fileBuffer: Buffer, mimeType: string): MessageContent | null {
return createFileContentFromBase64(bufferToBase64(fileBuffer), mimeType)
}
/**
* Create message content from base64-encoded file data.
*/
export function createFileContentFromBase64(
base64: string,
mimeType: string
): MessageContent | null {
// SVG is XML text — Claude only supports raster image formats (JPEG, PNG, GIF, WebP),
// so send SVGs as an XML document instead
if (mimeType.toLowerCase() === 'image/svg+xml') {
return {
type: 'document',
source: {
type: 'base64',
media_type: 'text/xml',
data: base64,
},
}
}
const contentType = getContentType(mimeType)
if (!contentType) {
return null
}
if (contentType === 'image' && !MODEL_SUPPORTED_IMAGE_MIME_TYPES.has(mimeType.toLowerCase())) {
return null
}
return {
type: contentType,
source: {
type: 'base64',
media_type: mimeType,
data: base64,
},
}
}
export const MODEL_SUPPORTED_IMAGE_MIME_TYPES = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
])
/**
* Extract file extension from filename
*/
export function getFileExtension(filename: string): string {
const lastDot = filename.lastIndexOf('.')
return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : ''
}
const EXTENSION_TO_MIME: Record<string, string> = {
// Images
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
bmp: 'image/bmp',
tif: 'image/tiff',
tiff: 'image/tiff',
heic: 'image/heic',
heif: 'image/heif',
avif: 'image/avif',
ico: 'image/x-icon',
// Documents
pdf: 'application/pdf',
txt: 'text/plain',
csv: 'text/csv',
json: 'application/json',
xml: 'application/xml',
html: 'text/html',
htm: 'text/html',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
doc: 'application/msword',
xls: 'application/vnd.ms-excel',
ppt: 'application/vnd.ms-powerpoint',
md: 'text/markdown',
yaml: 'application/x-yaml',
yml: 'application/x-yaml',
rtf: 'application/rtf',
// Archives
zip: 'application/zip',
gz: 'application/gzip',
// Code / plain-text source
py: 'text/x-python',
js: 'text/javascript',
mjs: 'text/javascript',
cjs: 'text/javascript',
ts: 'text/typescript',
tsx: 'text/typescript',
jsx: 'text/javascript',
go: 'text/x-go',
rs: 'text/x-rust',
java: 'text/x-java',
kt: 'text/x-kotlin',
c: 'text/x-c',
cpp: 'text/x-c++',
h: 'text/x-c',
hpp: 'text/x-c++',
cs: 'text/x-csharp',
rb: 'text/x-ruby',
php: 'text/x-php',
swift: 'text/x-swift',
sh: 'text/x-shellscript',
bash: 'text/x-shellscript',
zsh: 'text/x-shellscript',
r: 'text/x-r',
sql: 'text/x-sql',
scala: 'text/x-scala',
lua: 'text/x-lua',
pl: 'text/x-perl',
toml: 'text/x-toml',
ini: 'text/plain',
cfg: 'text/plain',
conf: 'text/plain',
env: 'text/plain',
log: 'text/plain',
makefile: 'text/x-makefile',
dockerfile: 'text/x-dockerfile',
css: 'text/css',
scss: 'text/x-scss',
less: 'text/x-less',
graphql: 'text/x-graphql',
gql: 'text/x-graphql',
proto: 'text/x-protobuf',
// Audio
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
wav: 'audio/wav',
webm: 'audio/webm',
ogg: 'audio/ogg',
flac: 'audio/flac',
aac: 'audio/aac',
opus: 'audio/opus',
// Video
mp4: 'video/mp4',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
mkv: 'video/x-matroska',
}
/**
* Get MIME type from file extension (fallback if not provided)
*/
export function getMimeTypeFromExtension(extension: string): string {
return EXTENSION_TO_MIME[extension.toLowerCase()] || 'application/octet-stream'
}
/**
* Resolve a reliable MIME type from a file, falling back to the extension map
* when the browser reports an empty type. By default treats
* `application/octet-stream` as "unknown" and falls back to the extension —
* pass `{ preserveOctetStream: true }` for direct PUT uploads where the
* browser-supplied content-type must match the presigned handshake exactly.
*/
export function resolveFileType(
file: { type: string; name: string },
options?: { preserveOctetStream?: boolean }
): string {
const browserType = file.type?.trim()
if (browserType) {
if (options?.preserveOctetStream || browserType !== 'application/octet-stream') {
return browserType
}
}
return getMimeTypeFromExtension(getFileExtension(file.name))
}
/**
* Upload `Content-Type` for direct PUT — preserves the browser's reported type
* verbatim (including `application/octet-stream`) so it matches the presigned
* URL's signed Content-Type header.
*/
export function getFileContentType(file: File): string {
return resolveFileType(file, { preserveOctetStream: true })
}
/**
* Whether `error` is a DOM `AbortError` (XHR `abort()`, fetch `signal.aborted`,
* etc). Used in upload retry loops so aborts short-circuit instead of retrying.
*/
export function isAbortError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'name' in error &&
String((error as { name?: unknown }).name) === 'AbortError'
)
}
/**
* Heuristic: whether `error` is a transient network/connection failure that's
* worth retrying (vs. a deterministic 4xx/auth/validation error). Sniffs the
* message because browsers and servers report these without standardized codes.
*/
export function isNetworkError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const message = error.message.toLowerCase()
return (
message.includes('network') ||
message.includes('fetch') ||
message.includes('connection') ||
message.includes('timeout') ||
message.includes('timed out') ||
message.includes('econnreset')
)
}
const MIME_TO_EXTENSION: Record<string, string> = {
// Images
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'image/bmp': 'bmp',
'image/tiff': 'tiff',
'image/heic': 'heic',
'image/heif': 'heif',
'image/avif': 'avif',
'image/x-icon': 'ico',
'image/vnd.microsoft.icon': 'ico',
// Documents
'application/pdf': 'pdf',
'text/plain': 'txt',
'text/csv': 'csv',
'application/json': 'json',
'application/xml': 'xml',
'text/xml': 'xml',
'text/html': 'html',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
'application/msword': 'doc',
'application/vnd.ms-excel': 'xls',
'application/vnd.ms-powerpoint': 'ppt',
'text/markdown': 'md',
'application/rtf': 'rtf',
// Audio
'audio/mpeg': 'mp3',
'audio/mp3': 'mp3',
'audio/mp4': 'm4a',
'audio/x-m4a': 'm4a',
'audio/m4a': 'm4a',
'audio/wav': 'wav',
'audio/wave': 'wav',
'audio/x-wav': 'wav',
'audio/webm': 'webm',
'audio/ogg': 'ogg',
'audio/vorbis': 'ogg',
'audio/flac': 'flac',
'audio/x-flac': 'flac',
'audio/aac': 'aac',
'audio/x-aac': 'aac',
'audio/opus': 'opus',
// Video
'video/mp4': 'mp4',
'video/mpeg': 'mpg',
'video/quicktime': 'mov',
'video/x-quicktime': 'mov',
'video/x-msvideo': 'avi',
'video/avi': 'avi',
'video/x-matroska': 'mkv',
'video/webm': 'webm',
// Archives
'application/zip': 'zip',
'application/x-zip-compressed': 'zip',
'application/gzip': 'gz',
}
/**
* Get file extension from MIME type
* @param mimeType - MIME type string
* @returns File extension without dot, or null if not found
*/
export function getExtensionFromMimeType(mimeType: string): string | null {
return MIME_TO_EXTENSION[mimeType.toLowerCase()] || null
}
/**
* Format bytes to human-readable file size
* @param bytes - File size in bytes
* @param options - Formatting options
* @returns Formatted string (e.g., "1.5 MB", "500 KB")
*/
export function formatFileSize(
bytes: number,
options?: { includeBytes?: boolean; precision?: number }
): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const precision = options?.precision ?? 1
const includeBytes = options?.includeBytes ?? false
const i = Math.floor(Math.log(bytes) / Math.log(k))
if (i === 0 && !includeBytes) {
return '0 Bytes'
}
const value = bytes / k ** i
const formattedValue = Number.parseFloat(value.toFixed(precision))
return `${formattedValue} ${sizes[i]}`
}
/**
* Validate file size and type for knowledge base uploads (client-side)
* @param file - File object to validate
* @param maxSizeBytes - Maximum file size in bytes (default: 100MB)
* @returns Error message string if validation fails, null if valid
*/
export function validateKnowledgeBaseFile(
file: File,
maxSizeBytes: number = 100 * 1024 * 1024
): string | null {
if (file.size > maxSizeBytes) {
const maxSizeMB = Math.round(maxSizeBytes / (1024 * 1024))
return `File "${file.name}" is too large. Maximum size is ${maxSizeMB}MB.`
}
// Check MIME type first
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
return null
}
// Fallback: check file extension (browsers often misidentify file types like .md)
const extension = getFileExtension(file.name)
if (
SUPPORTED_DOCUMENT_EXTENSIONS.includes(
extension as (typeof SUPPORTED_DOCUMENT_EXTENSIONS)[number]
)
) {
return null
}
return `File "${file.name}" has an unsupported format. Please use PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON, JSONL, YAML, or YML files.`
}
/**
* Extract storage key from a file path
* Handles URLs like /api/files/serve/s3/key or /api/files/serve/blob/key
*/
export function extractStorageKey(filePath: string): string {
let pathWithoutQuery = filePath.split('?')[0]
try {
if (pathWithoutQuery.startsWith('http://') || pathWithoutQuery.startsWith('https://')) {
const url = new URL(pathWithoutQuery)
pathWithoutQuery = url.pathname
}
} catch {
// If URL parsing fails, use the original path
}
if (pathWithoutQuery.startsWith('/api/files/serve/')) {
let key = decodeURIComponent(pathWithoutQuery.substring('/api/files/serve/'.length))
if (key.startsWith('s3/')) {
key = key.substring(3)
} else if (key.startsWith('blob/')) {
key = key.substring(5)
}
return key
}
return pathWithoutQuery
}
/**
* Whether a URL targets the internal file-serve endpoint (`/api/files/serve/`).
*
* The marker is matched only in the URL's path component, so it cannot be
* smuggled through a query string or fragment (e.g.
* `https://evil.com/x?next=/api/files/serve/...`) to skip DNS/SSRF validation.
*
* The raw path is inspected without URL normalization on purpose: callers such
* as the files parse route rely on traversal sequences (`..`) surviving this
* check so they are rejected downstream rather than collapsed away. A path-only
* marker still classifies any host as internal (e.g.
* `https://other-host/api/files/serve/<key>`); cross-tenant reads are prevented
* at the storage sink by {@link verifyFileAccess}, not by host matching, which
* would break self-hosted and multi-domain deployments.
*/
export function isInternalFileUrl(fileUrl: string): boolean {
if (typeof fileUrl !== 'string') {
return false
}
let path = fileUrl
const scheme = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i.exec(path)
if (scheme) {
path = path.slice(scheme[0].length)
}
path = path.split(/[?#]/, 1)[0]
return path.startsWith('/api/files/serve/')
}
/**
* Infer storage context from a file key using its prefix.
*
* All stored files use prefixed keys. Knowledge-base objects carry one of two
* prefixes: `kb/` (server-side uploads) or `knowledge-base/` (direct/presigned
* uploads, whose default key is `${context}/...`). Both map to the same
* `knowledge-base` context.
*/
export function inferContextFromKey(key: string): StorageContext {
if (!key) {
throw new Error('Cannot infer context from empty key')
}
if (key.startsWith('kb/') || key.startsWith('knowledge-base/')) return 'knowledge-base'
if (key.startsWith('chat/')) return 'chat'
if (key.startsWith('copilot/')) return 'copilot'
if (key.startsWith('execution/')) return 'execution'
if (key.startsWith('workspace/')) return 'workspace'
if (key.startsWith('profile-pictures/')) return 'profile-pictures'
if (key.startsWith('og-images/')) return 'og-images'
if (key.startsWith('workspace-logos/')) return 'workspace-logos'
if (key.startsWith('logs/')) return 'logs'
throw new Error(
`File key must start with a context prefix (kb/, knowledge-base/, chat/, copilot/, execution/, workspace/, profile-pictures/, og-images/, workspace-logos/, or logs/). Got: ${key}`
)
}
/**
* World-readable storage contexts. Reads for these short-circuit file
* authorization and can resolve to the shared bucket, so a caller-supplied
* context must never select one for a key that does not carry the matching
* prefix.
*/
const PUBLIC_STORAGE_CONTEXTS = new Set<StorageContext>([
'profile-pictures',
'og-images',
'workspace-logos',
])
/**
* Resolve the storage context for a stored file from its trusted key prefix.
*
* The storage key is written server-side at upload time and cannot be forged to
* change tenant, whereas a file's `context` field is attacker-authorable in a
* workflow. When the key carries a recognized prefix that prefix is
* authoritative and the caller-supplied `context` is ignored — this prevents a
* private `workspace/…` key from being relabeled with a world-readable context
* to bypass authorization and read the shared bucket.
*
* Legacy keys predating context-prefixed keys cannot be inferred; for those the
* persisted `context` is honored so existing files stay resolvable — except a
* world-readable context, which would reopen the bypass on an un-inferrable key.
*/
export function resolveTrustedFileContext(key: string, context?: string): StorageContext {
try {
return inferContextFromKey(key)
} catch (error) {
if (context && !PUBLIC_STORAGE_CONTEXTS.has(context as StorageContext)) {
return context as StorageContext
}
throw error
}
}
/**
* Extract storage key and context from an internal file URL
* @param fileUrl - Internal file URL (e.g., /api/files/serve/key?context=workspace)
* @returns Object with storage key and context
*/
export function parseInternalFileUrl(fileUrl: string): { key: string; context: StorageContext } {
const key = extractStorageKey(fileUrl)
if (!key) {
throw new Error('Could not extract storage key from internal file URL')
}
const url = new URL(fileUrl.startsWith('http') ? fileUrl : `http://localhost${fileUrl}`)
const contextParam = url.searchParams.get('context')
const context = (contextParam as StorageContext) || inferContextFromKey(key)
return { key, context }
}
/**
* Raw file input that can be converted to UserFile
* Supports various file object formats from different sources
*/
export interface RawFileInput {
id?: string
key?: string
path?: string
url?: string
name: string
size: number
type?: string
uploadedAt?: string | Date
expiresAt?: string | Date
context?: string
base64?: string
}
/**
* Type guard to check if a RawFileInput has all UserFile required properties
*/
function isCompleteUserFile(file: RawFileInput): file is UserFile {
return (
typeof file.id === 'string' &&
typeof file.name === 'string' &&
typeof file.url === 'string' &&
typeof file.size === 'number' &&
typeof file.type === 'string' &&
typeof file.key === 'string'
)
}
function isUrlLike(value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/')
}
/**
* Extracts HTTPS URL from a file input object (UserFile or RawFileInput)
* Returns null if no valid HTTPS URL is found
*/
export function resolveHttpsUrlFromFileInput(fileInput: unknown): string | null {
if (!fileInput || typeof fileInput !== 'object') {
return null
}
const record = fileInput as Record<string, unknown>
const url =
typeof record.url === 'string'
? record.url.trim()
: typeof record.path === 'string'
? record.path.trim()
: ''
if (!url || !url.startsWith('https://')) {
return null
}
return url
}
function resolveStorageKeyFromRawFile(file: RawFileInput): string | null {
if (file.key) {
return file.key
}
if (file.path) {
if (isUrlLike(file.path)) {
return isInternalFileUrl(file.path) ? extractStorageKey(file.path) : null
}
return file.path
}
if (file.url) {
return isInternalFileUrl(file.url) ? extractStorageKey(file.url) : null
}
return null
}
function resolveInternalFileUrl(file: RawFileInput): string {
if (file.url && isInternalFileUrl(file.url)) {
return file.url
}
if (file.path && isInternalFileUrl(file.path)) {
return file.path
}
return ''
}
/**
* Provider large-file handles are populated by the server pipeline and must never be
* accepted from untrusted file input (they drive server-side fetch/upload).
*/
const PROVIDER_FILE_HANDLE_FIELDS: Array<'providerFileId' | 'providerFileUri' | 'remoteUrl'> = [
'providerFileId',
'providerFileUri',
'remoteUrl',
]
/**
* Core conversion logic from RawFileInput to UserFile
*/
function convertToUserFile(file: RawFileInput, requestId: string, logger: Logger): UserFile | null {
if (isCompleteUserFile(file)) {
return {
...omit(file, PROVIDER_FILE_HANDLE_FIELDS),
url: resolveInternalFileUrl(file) || file.url,
}
}
const storageKey = resolveStorageKeyFromRawFile(file)
if (!storageKey) {
return null
}
const userFile: UserFile = {
id: file.id || `file-${Date.now()}`,
name: file.name,
url: resolveInternalFileUrl(file),
size: file.size,
type: file.type || 'application/octet-stream',
key: storageKey,
context: file.context,
base64: file.base64,
}
logger.info(`[${requestId}] Converted file to UserFile: ${userFile.name} (key: ${userFile.key})`)
return userFile
}
/**
* Converts a single raw file object to UserFile format
* @throws Error if file is an array or has no storage key
*/
export function processSingleFileToUserFile(
file: RawFileInput,
requestId: string,
logger: Logger
): UserFile {
if (Array.isArray(file)) {
const errorMsg = `Expected a single file but received an array with ${file.length} file(s). Use a file input that accepts multiple files, or select a specific file from the array (e.g., {{block.files[0]}}).`
logger.error(`[${requestId}] ${errorMsg}`)
throw new Error(errorMsg)
}
const userFile = convertToUserFile(file, requestId, logger)
if (!userFile) {
const errorMsg = `File has no storage key: ${file.name || 'unknown'}`
logger.warn(`[${requestId}] ${errorMsg}`)
throw new Error(errorMsg)
}
return userFile
}
/**
* Converts raw file objects to UserFile format, accepting single or array input
*/
export function processFilesToUserFiles(
files: RawFileInput | RawFileInput[],
requestId: string,
logger: Logger
): UserFile[] {
const filesArray = Array.isArray(files) ? files : [files]
const userFiles: UserFile[] = []
for (const file of filesArray) {
if (Array.isArray(file)) {
logger.warn(`[${requestId}] Skipping nested array in file input`)
continue
}
const userFile = convertToUserFile(file, requestId, logger)
if (userFile) {
userFiles.push(userFile)
} else {
logger.warn(`[${requestId}] Skipping file without storage key: ${file.name || 'unknown'}`)
}
}
return userFiles
}
/**
* Sanitize a filename for use in storage metadata headers
* Storage metadata headers must contain only ASCII printable characters (0x20-0x7E)
* and cannot contain certain special characters
*/
export function sanitizeFilenameForMetadata(filename: string): string {
return (
filename
// Remove non-ASCII characters (keep only printable ASCII 0x20-0x7E)
.replace(/[^\x20-\x7E]/g, '')
// Remove characters that are problematic in HTTP headers
.replace(/["\\]/g, '')
// Replace multiple spaces with single space
.replace(/\s+/g, ' ')
// Trim whitespace
.trim() ||
// Provide fallback if completely sanitized
'file'
)
}
/**
* Sanitize metadata values for storage providers
* Removes non-printable ASCII characters and limits length
* @param metadata Original metadata object
* @param maxLength Maximum length per value (Azure Blob: 8000, S3: 2000)
* @returns Sanitized metadata object
*/
export function sanitizeStorageMetadata(
metadata: Record<string, string>,
maxLength: number
): Record<string, string> {
const sanitized: Record<string, string> = {}
for (const [key, value] of Object.entries(metadata)) {
const sanitizedValue = String(value)
.replace(/[^\x20-\x7E]/g, '')
.replace(/["\\]/g, '')
.substring(0, maxLength)
if (sanitizedValue) {
sanitized[key] = sanitizedValue
}
}
return sanitized
}
/**
* Sanitize a file key/path for local storage
* Removes dangerous characters and prevents path traversal
* Preserves forward slashes for structured paths (e.g., kb/file.json, workspace/id/file.json)
* All keys must have a context prefix structure
* @param key Original file key/path
* @returns Sanitized key safe for filesystem use
*/
export function sanitizeFileKey(key: string): string {
if (!key.includes('/')) {
throw new Error('File key must include a context prefix (e.g., kb/, workspace/, execution/)')
}
const segments = key.split('/')
const sanitizedSegments = segments.map((segment, index) => {
if (segment === '..' || segment === '.') {
throw new Error('Path traversal detected in file key')
}
if (index === segments.length - 1) {
return segment.replace(/[^a-zA-Z0-9.-]/g, '_')
}
return segment.replace(/[^a-zA-Z0-9-]/g, '_')
})
return sanitizedSegments.join('/')
}
/**
* Extract clean filename from URL or path, stripping query parameters
* Handles both internal serve URLs (/api/files/serve/...) and external URLs
* @param urlOrPath URL or path string that may contain query parameters
* @returns Clean filename without query parameters
*/
export function extractCleanFilename(urlOrPath: string): string {
const withoutQuery = urlOrPath.split('?')[0]
try {
const url = new URL(
withoutQuery.startsWith('http') ? withoutQuery : `http://localhost${withoutQuery}`
)
const pathname = url.pathname
const filename = pathname.split('/').pop() || 'unknown'
return decodeURIComponent(filename)
} catch {
const filename = withoutQuery.split('/').pop() || 'unknown'
return decodeURIComponent(filename)
}
}
/**
* Extract workspaceId from execution file key pattern
* Format: execution/workspaceId/workflowId/executionId/filename
* @param key File storage key
* @returns workspaceId if key matches execution file pattern, null otherwise
*/
export function extractWorkspaceIdFromExecutionKey(key: string): string | null {
const segments = key.split('/')
if (segments[0] === 'execution' && segments.length >= 5) {
const workspaceId = segments[1]
if (workspaceId && isUuid(workspaceId)) {
return workspaceId
}
}
return null
}
/**
* Construct viewer URL for a file
* Viewer URL format: /workspace/{workspaceId}/files/{fileKey}
* @param fileKey File storage key
* @param workspaceId Optional workspace ID (will be extracted from key if not provided)
* @returns Viewer URL string or null if workspaceId cannot be determined
*/
export function getViewerUrl(fileKey: string, workspaceId?: string): string | null {
const resolvedWorkspaceId = workspaceId || extractWorkspaceIdFromExecutionKey(fileKey)
if (!resolvedWorkspaceId) {
return null
}
return `/workspace/${resolvedWorkspaceId}/files/${fileKey}`
}
@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server'
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile'
/**
* Canonical retryable response for an attachment/upload whose generated-document
* artifact is still compiling. Returns the 409 when `error` is a
* {@link DocCompileUserError} (thrown by `downloadServableFileFromStorage`),
* otherwise `null` so the caller falls through to its own error handling. Shared
* by every tool route that downloads workspace files so the status, body shape,
* and user-facing copy stay identical instead of being re-typed per route.
*/
export function docNotReadyResponse(error: unknown): NextResponse | null {
if (error instanceof DocCompileUserError) {
return NextResponse.json(
{
success: false,
error: 'A document is still being generated. Wait for it to finish, then try again.',
},
{ status: 409 }
)
}
return null
}
@@ -0,0 +1,388 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
cleanupExecutionBase64Cache,
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import type { UserFile } from '@/executor/types'
const { mockDownloadFile, mockGetRedisClient, mockRedis, mockVerifyFileAccess } = vi.hoisted(() => {
const mockRedis = {
get: vi.fn(),
set: vi.fn(),
hget: vi.fn(),
hset: vi.fn(),
hgetall: vi.fn(),
expire: vi.fn(),
scan: vi.fn(),
del: vi.fn(),
eval: vi.fn(),
}
return {
mockDownloadFile: vi.fn(),
mockGetRedisClient: vi.fn(),
mockRedis,
mockVerifyFileAccess: vi.fn(),
}
})
vi.mock('@/lib/core/config/redis', () => ({
getRedisClient: mockGetRedisClient,
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
downloadFile: mockDownloadFile,
},
}))
vi.mock('@/lib/uploads/contexts/execution/execution-file-manager', () => ({
downloadExecutionFile: mockDownloadFile,
}))
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
downloadFileFromStorage: mockDownloadFile,
}))
vi.mock('@/app/api/files/authorization', () => ({
verifyFileAccess: mockVerifyFileAccess,
}))
describe('hydrateUserFilesWithBase64', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetRedisClient.mockReturnValue(null)
mockRedis.get.mockResolvedValue(null)
mockRedis.set.mockResolvedValue('OK')
mockRedis.hget.mockResolvedValue(null)
mockRedis.hset.mockResolvedValue(1)
mockRedis.hgetall.mockResolvedValue({})
mockRedis.expire.mockResolvedValue(1)
mockRedis.scan.mockResolvedValue(['0', []])
mockRedis.del.mockResolvedValue(1)
mockRedis.eval.mockResolvedValue([1, 'ok', 0, 0])
mockVerifyFileAccess.mockResolvedValue(true)
})
it('strips existing base64 when it exceeds maxBytes', async () => {
const file: UserFile = {
id: 'file-1',
name: 'large.txt',
key: 'execution/workspace/workflow/execution/large.txt',
url: 'https://example.com/large.txt',
size: 5,
type: 'text/plain',
context: 'execution',
base64: Buffer.from('hello').toString('base64'),
}
const hydrated = await hydrateUserFilesWithBase64({ file }, { maxBytes: 1 })
expect(hydrated.file).not.toHaveProperty('base64')
})
it('keeps existing base64 when it is within maxBytes', async () => {
const base64 = Buffer.from('hello').toString('base64')
const file: UserFile = {
id: 'file-1',
name: 'small.txt',
key: 'execution/workspace/workflow/execution/small.txt',
url: 'https://example.com/small.txt',
size: 5,
type: 'text/plain',
context: 'execution',
base64,
}
const hydrated = await hydrateUserFilesWithBase64({ file }, { maxBytes: 10 })
expect(hydrated.file.base64).toBe(base64)
})
it('does not hydrate URL-only internal file objects', async () => {
const file: UserFile = {
id: 'file-1',
name: 'private.txt',
key: '',
url: '/api/files/serve/execution/workspace/workflow/execution/private.txt?context=execution',
size: 5,
type: 'text/plain',
}
const hydrated = await hydrateUserFilesWithBase64({ file }, { maxBytes: 10, userId: 'user-1' })
expect(hydrated.file).not.toHaveProperty('base64')
})
it('hydrates prior-execution files when workflow-scoped reads are enabled', async () => {
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello', 'utf8'))
const file: UserFile = {
id: 'file-1',
name: 'prior.txt',
key: 'execution/workspace/workflow/source-execution/prior.txt',
url: '/api/files/serve/execution/workspace/workflow/source-execution/prior.txt?context=execution',
size: 5,
type: 'text/plain',
context: 'execution',
}
const hydrated = await hydrateUserFilesWithBase64(
{ file },
{
workspaceId: 'workspace',
workflowId: 'workflow',
executionId: 'resume-execution',
allowLargeValueWorkflowScope: true,
userId: 'user-1',
maxBytes: 10,
}
)
expect(hydrated.file.base64).toBe(Buffer.from('hello').toString('base64'))
})
it('materializes large refs before hydrating nested files', async () => {
const file: UserFile = {
id: 'file-1',
name: 'nested.txt',
key: 'execution/workspace/workflow/source-execution/nested.txt',
url: '/api/files/serve/execution/workspace/workflow/source-execution/nested.txt?context=execution',
size: 5,
type: 'text/plain',
context: 'execution',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 256,
key: 'execution/workspace/workflow/source-execution/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'source-execution',
}
mockDownloadFile.mockImplementation(async ({ key }) => {
if (key.includes('large-value')) {
return Buffer.from(JSON.stringify({ file }), 'utf8')
}
return Buffer.from('hello', 'utf8')
})
const hydrated = await hydrateUserFilesWithBase64(
{ ref },
{
workspaceId: 'workspace',
workflowId: 'workflow',
executionId: 'resume-execution',
largeValueExecutionIds: ['source-execution'],
userId: 'user-1',
maxBytes: 1024,
}
)
expect((hydrated.ref as unknown as { file: UserFile }).file.base64).toBe(
Buffer.from('hello').toString('base64')
)
})
it('preserves large-value metadata while hydrating visible files when requested', async () => {
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello', 'utf8'))
const file: UserFile = {
id: 'file-1',
name: 'visible.txt',
key: 'execution/workspace/workflow/execution-1/visible.txt',
url: '/api/files/serve/execution/workspace/workflow/execution-1/visible.txt?context=execution',
size: 5,
type: 'text/plain',
context: 'execution',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_PRESERVEREF1',
kind: 'object',
size: 256,
key: 'execution/workspace/workflow/source-execution/large-value-lv_PRESERVEREF1.json',
executionId: 'source-execution',
}
const manifest = {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 256,
chunks: [
{
ref,
count: 1,
byteSize: 256,
},
],
preview: [{ id: 1 }],
}
const hydrated = await hydrateUserFilesWithBase64(
{ file, ref, manifest },
{
workspaceId: 'workspace',
workflowId: 'workflow',
executionId: 'execution-1',
userId: 'user-1',
maxBytes: 1024,
preserveLargeValueMetadata: true,
}
)
expect(hydrated.file.base64).toBe(Buffer.from('hello').toString('base64'))
expect(hydrated.ref).toBe(ref)
expect(hydrated.manifest).toBe(manifest)
expect(mockDownloadFile).toHaveBeenCalledOnce()
})
it('hydrates nested prior-execution files discovered from exact-key large refs', async () => {
const file: UserFile = {
id: 'file-1',
name: 'nested.txt',
key: 'execution/workspace/workflow/source-execution/nested.txt',
url: '/api/files/serve/execution/workspace/workflow/source-execution/nested.txt?context=execution',
size: 5,
type: 'text/plain',
context: 'execution',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_MNOPQRSTUVWX',
kind: 'object',
size: 256,
key: 'execution/workspace/workflow/source-execution/large-value-lv_MNOPQRSTUVWX.json',
executionId: 'source-execution',
}
mockDownloadFile.mockImplementation(async ({ key }) => {
if (key.includes('large-value')) {
return Buffer.from(JSON.stringify({ file }), 'utf8')
}
return Buffer.from('hello', 'utf8')
})
const hydrated = await hydrateUserFilesWithBase64(
{ ref },
{
workspaceId: 'workspace',
workflowId: 'workflow',
executionId: 'resume-execution',
largeValueKeys: [ref.key],
userId: 'user-1',
maxBytes: 1024,
}
)
expect((hydrated.ref as unknown as { file: UserFile }).file.base64).toBe(
Buffer.from('hello').toString('base64')
)
})
it('releases reserved Redis budget when cleaning up execution cache entries', async () => {
mockGetRedisClient.mockReturnValue(mockRedis)
const rawEntry = JSON.stringify({ bytes: 12, userId: 'user-1' })
mockRedis.hgetall.mockResolvedValueOnce({
'key:file-1': rawEntry,
})
mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => {
if (script.includes('HGET') && script.includes('HDEL') && script.includes('DECRBY')) {
expect(args).toEqual([
4,
'user-file:base64-budget:exec:exec-1',
'user-file:base64:exec:exec-1:key:file-1',
'execution:redis-budget:execution:exec-1',
'execution:redis-budget:user:user-1',
'key:file-1',
rawEntry,
12,
60 * 60,
])
return [1, 1]
}
return 1
})
await cleanupExecutionBase64Cache('exec-1')
expect(mockRedis.eval).toHaveBeenCalledOnce()
})
it('releases indexed budget entries even when cache keys already expired', async () => {
mockGetRedisClient.mockReturnValue(mockRedis)
mockRedis.hgetall.mockResolvedValueOnce({
'key:file-1': JSON.stringify({ bytes: 7, userId: 'user-1' }),
})
mockRedis.eval.mockResolvedValueOnce([1, 0])
await cleanupExecutionBase64Cache('exec-1')
expect(mockRedis.eval).toHaveBeenCalledOnce()
})
it('writes execution cache and budget index through one delta-aware script', async () => {
mockGetRedisClient.mockReturnValue(mockRedis)
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello world!', 'utf8'))
let reservedBytes = 0
mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => {
if (script.includes('HGET') && script.includes('HSET') && script.includes('SET')) {
const keyCount = Number(args[0])
const valueBytes = Number(args[keyCount + 5])
reservedBytes = valueBytes - 10
return [1, 'ok', reservedBytes, reservedBytes]
}
return 1
})
const file: UserFile = {
id: 'file-1',
name: 'delta.txt',
key: 'execution/workspace/workflow/exec-1/delta.txt',
url: '/api/files/serve/execution/workspace/workflow/exec-1/delta.txt?context=execution',
size: 12,
type: 'text/plain',
context: 'execution',
}
const hydrated = await hydrateUserFilesWithBase64(
{ file },
{
workspaceId: 'workspace',
workflowId: 'workflow',
executionId: 'exec-1',
userId: 'user-1',
maxBytes: 20,
}
)
expect(hydrated.file.base64).toBe(Buffer.from('hello world!').toString('base64'))
expect(reservedBytes).toBe(Buffer.from('hello world!').toString('base64').length - 10)
expect(mockRedis.eval).toHaveBeenCalledWith(
expect.stringContaining('HGET'),
4,
'user-file:base64:exec:exec-1:key:execution/workspace/workflow/exec-1/delta.txt',
'user-file:base64-budget:exec:exec-1',
'execution:redis-budget:execution:exec-1',
'execution:redis-budget:user:user-1',
Buffer.from('hello world!').toString('base64'),
60 * 60,
'key:execution/workspace/workflow/exec-1/delta.txt',
JSON.stringify({
bytes: Buffer.from('hello world!').toString('base64').length,
userId: 'user-1',
}),
Buffer.from('hello world!').toString('base64').length,
64 * 1024 * 1024,
256 * 1024 * 1024,
60 * 60
)
expect(mockRedis.hget).not.toHaveBeenCalled()
expect(mockRedis.set).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,636 @@
import type { Logger } from '@sim/logger'
import { createLogger } from '@sim/logger'
import { isPlainRecord } from '@sim/utils/object'
import { getRedisClient } from '@/lib/core/config/redis'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
import {
isLargeArrayManifest,
materializeLargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest'
import {
getLargeValueMaterializationError,
isLargeValueRef,
LARGE_VALUE_THRESHOLD_BYTES,
} from '@/lib/execution/payloads/large-value-ref'
import {
assertUserFileContentAccess,
readUserFileContent,
} from '@/lib/execution/payloads/materialization.server'
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
import {
type ExecutionRedisBudgetReservation,
getExecutionRedisBudgetKeys,
getExecutionRedisBudgetLimits,
} from '@/lib/execution/redis-budget.server'
import {
ExecutionResourceLimitError,
isExecutionResourceLimitError,
} from '@/lib/execution/resource-errors'
import type { UserFile } from '@/executor/types'
const INLINE_BASE64_JSON_OVERHEAD_BYTES = 512 * 1024
const DEFAULT_MAX_BASE64_BYTES = Math.floor(
(LARGE_VALUE_THRESHOLD_BYTES - INLINE_BASE64_JSON_OVERHEAD_BYTES) * 0.75
)
const DEFAULT_CACHE_TTL_SECONDS = 300
const REDIS_KEY_PREFIX = 'user-file:base64:'
const REDIS_BUDGET_KEY_PREFIX = 'user-file:base64-budget:'
const CLEANUP_BASE64_CACHE_ENTRY_SCRIPT = `
local file_key = ARGV[1]
local expected_entry = ARGV[2]
local bytes = tonumber(ARGV[3])
local budget_ttl_seconds = tonumber(ARGV[4])
local current_entry = redis.call('HGET', KEYS[1], file_key)
if not current_entry or current_entry ~= expected_entry then
return {0, 0}
end
local deleted = redis.call('DEL', KEYS[2])
redis.call('HDEL', KEYS[1], file_key)
if bytes and bytes > 0 then
local execution_next = redis.call('DECRBY', KEYS[3], bytes)
if execution_next <= 0 then
redis.call('DEL', KEYS[3])
else
redis.call('EXPIRE', KEYS[3], budget_ttl_seconds)
end
if #KEYS >= 4 then
local user_next = redis.call('DECRBY', KEYS[4], bytes)
if user_next <= 0 then
redis.call('DEL', KEYS[4])
else
redis.call('EXPIRE', KEYS[4], budget_ttl_seconds)
end
end
end
if redis.call('HLEN', KEYS[1]) == 0 then
redis.call('DEL', KEYS[1])
end
return {1, deleted}
`
const SET_BASE64_CACHE_SCRIPT = `
local value = ARGV[1]
local cache_ttl_seconds = tonumber(ARGV[2])
local file_key = ARGV[3]
local next_entry = ARGV[4]
local next_bytes = tonumber(ARGV[5])
local execution_limit = tonumber(ARGV[6])
local user_limit = tonumber(ARGV[7])
local budget_ttl_seconds = tonumber(ARGV[8])
local previous_entry = redis.call('HGET', KEYS[2], file_key)
local previous_bytes = 0
if previous_entry then
local parsed_previous_bytes = string.match(previous_entry, '"bytes"%s*:%s*(%d+)')
if parsed_previous_bytes then
previous_bytes = tonumber(parsed_previous_bytes)
end
end
local execution_current_raw = redis.call('GET', KEYS[3])
local execution_current = tonumber(execution_current_raw or '0')
local execution_delta = next_bytes - previous_bytes
if not execution_current_raw then
execution_delta = next_bytes
end
if execution_delta > 0 and execution_limit > 0 and execution_current + execution_delta > execution_limit then
return {0, 'execution_redis_bytes', execution_current}
end
local user_delta = 0
local user_current = 0
local user_current_raw = nil
if #KEYS >= 4 then
user_current_raw = redis.call('GET', KEYS[4])
user_current = tonumber(user_current_raw or '0')
user_delta = next_bytes - previous_bytes
if not user_current_raw then
user_delta = next_bytes
end
if user_delta > 0 and user_limit > 0 and user_current + user_delta > user_limit then
return {0, 'user_redis_bytes', user_current}
end
end
if execution_delta > 0 then
redis.call('INCRBY', KEYS[3], execution_delta)
elseif execution_delta < 0 and execution_current_raw then
local execution_next = redis.call('DECRBY', KEYS[3], -execution_delta)
if execution_next <= 0 then
redis.call('DEL', KEYS[3])
end
end
if redis.call('EXISTS', KEYS[3]) == 1 then
redis.call('EXPIRE', KEYS[3], budget_ttl_seconds)
end
if #KEYS >= 4 then
if user_delta > 0 then
redis.call('INCRBY', KEYS[4], user_delta)
elseif user_delta < 0 and user_current_raw then
local user_next = redis.call('DECRBY', KEYS[4], -user_delta)
if user_next <= 0 then
redis.call('DEL', KEYS[4])
end
end
if redis.call('EXISTS', KEYS[4]) == 1 then
redis.call('EXPIRE', KEYS[4], budget_ttl_seconds)
end
end
redis.call('SET', KEYS[1], value, 'EX', cache_ttl_seconds)
redis.call('HSET', KEYS[2], file_key, next_entry)
redis.call('EXPIRE', KEYS[2], cache_ttl_seconds)
return {1, 'ok', execution_delta, user_delta}
`
interface Base64BudgetEntry {
bytes: number
userId?: string
}
interface Base64Cache {
get(file: UserFile): Promise<string | null>
set(file: UserFile, value: string, ttlSeconds: number): Promise<void>
}
interface HydrationState {
seen: WeakSet<object>
cache: Base64Cache
cacheTtlSeconds: number
}
export interface Base64HydrationOptions {
requestId?: string
workspaceId?: string
workflowId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
logger?: Logger
maxBytes?: number
allowUnknownSize?: boolean
timeoutMs?: number
cacheTtlSeconds?: number
preserveLargeValueMetadata?: boolean
}
class InMemoryBase64Cache implements Base64Cache {
private entries = new Map<string, { value: string; expiresAt: number }>()
async get(file: UserFile): Promise<string | null> {
const key = getFileCacheKey(file)
const entry = this.entries.get(key)
if (!entry) {
return null
}
if (entry.expiresAt <= Date.now()) {
this.entries.delete(key)
return null
}
return entry.value
}
async set(file: UserFile, value: string, ttlSeconds: number): Promise<void> {
const key = getFileCacheKey(file)
const expiresAt = Date.now() + ttlSeconds * 1000
this.entries.set(key, { value, expiresAt })
}
}
function createBase64Cache(options: Base64HydrationOptions, logger: Logger): Base64Cache {
const redis = getRedisClient()
const { executionId } = options
if (!redis) {
logger.warn(
`[${options.requestId}] Redis unavailable for base64 cache, using in-memory fallback`
)
return new InMemoryBase64Cache()
}
return {
async get(file: UserFile) {
try {
const key = getFullCacheKey(executionId, file)
return await redis.get(key)
} catch (error) {
logger.warn(`[${options.requestId}] Redis get failed, skipping cache`, error)
return null
}
},
async set(file: UserFile, value: string, ttlSeconds: number) {
const key = getFullCacheKey(executionId, file)
const valueBytes = Buffer.byteLength(value, 'utf8')
try {
if (!executionId) {
await redis.set(key, value, 'EX', ttlSeconds)
return
}
const limits = getExecutionRedisBudgetLimits()
if (valueBytes > limits.maxSingleWriteBytes) {
throw new ExecutionResourceLimitError({
resource: 'redis_key_bytes',
attemptedBytes: valueBytes,
limitBytes: limits.maxSingleWriteBytes,
})
}
const cacheTtlSeconds = Math.max(ttlSeconds, limits.ttlSeconds)
const budgetReservation: ExecutionRedisBudgetReservation = {
executionId,
userId: options.userId,
category: 'base64_cache',
operation: 'set_base64_cache',
bytes: valueBytes,
logger,
}
const budgetKeys = getExecutionRedisBudgetKeys(budgetReservation)
const result = (await redis.eval(
SET_BASE64_CACHE_SCRIPT,
2 + budgetKeys.length,
key,
getBudgetIndexKey(executionId),
...budgetKeys,
value,
cacheTtlSeconds,
getFileCacheKey(file),
serializeBudgetEntry({ bytes: valueBytes, userId: options.userId }),
valueBytes,
limits.maxExecutionBytes,
limits.maxUserBytes,
limits.ttlSeconds
)) as [number, string, number | string | null]
const [allowed, resource, current] = result
if (allowed !== 1) {
throw new ExecutionResourceLimitError({
resource:
resource === 'user_redis_bytes' ? 'user_redis_bytes' : 'execution_redis_bytes',
attemptedBytes: valueBytes,
currentBytes: Number(current ?? 0),
limitBytes:
resource === 'user_redis_bytes' ? limits.maxUserBytes : limits.maxExecutionBytes,
})
}
} catch (error) {
if (isExecutionResourceLimitError(error)) {
throw error
}
logger.warn(`[${options.requestId}] Redis set failed, skipping cache`, error)
}
},
}
}
function createHydrationState(options: Base64HydrationOptions, logger: Logger): HydrationState {
return {
seen: new WeakSet<object>(),
cache: createBase64Cache(options, logger),
cacheTtlSeconds: options.cacheTtlSeconds ?? DEFAULT_CACHE_TTL_SECONDS,
}
}
function getHydrationLogger(options: Base64HydrationOptions): Logger {
return options.logger ?? createLogger('UserFileBase64')
}
function getFileCacheKey(file: UserFile): string {
if (file.key) {
return `key:${file.key}`
}
if (file.url) {
return `url:${file.url}`
}
return `id:${file.id}`
}
function getFullCacheKey(executionId: string | undefined, file: UserFile): string {
const fileKey = getFileCacheKey(file)
if (executionId) {
return `${REDIS_KEY_PREFIX}exec:${executionId}:${fileKey}`
}
return `${REDIS_KEY_PREFIX}${fileKey}`
}
function getBudgetIndexKey(executionId: string): string {
return `${REDIS_BUDGET_KEY_PREFIX}exec:${executionId}`
}
function serializeBudgetEntry(entry: Base64BudgetEntry): string {
return JSON.stringify(entry)
}
function parseBudgetEntry(value: unknown): Base64BudgetEntry | null {
if (typeof value !== 'string') {
return null
}
try {
const parsed = JSON.parse(value) as Partial<Base64BudgetEntry>
if (typeof parsed.bytes !== 'number' || !Number.isFinite(parsed.bytes) || parsed.bytes <= 0) {
return null
}
return {
bytes: parsed.bytes,
userId: typeof parsed.userId === 'string' ? parsed.userId : undefined,
}
} catch {
return null
}
}
async function cleanupBudgetEntry(
redis: NonNullable<ReturnType<typeof getRedisClient>>,
executionId: string,
fileKey: string,
rawEntry: string,
entry: Base64BudgetEntry
): Promise<{ claimed: boolean; deletedCount: number }> {
const limits = getExecutionRedisBudgetLimits()
const budgetReservation: ExecutionRedisBudgetReservation = {
executionId,
userId: entry.userId,
category: 'base64_cache',
operation: 'cleanup_base64_cache',
bytes: entry.bytes,
}
const budgetKeys = getExecutionRedisBudgetKeys(budgetReservation)
const result = (await redis.eval(
CLEANUP_BASE64_CACHE_ENTRY_SCRIPT,
2 + budgetKeys.length,
getBudgetIndexKey(executionId),
`${REDIS_KEY_PREFIX}exec:${executionId}:${fileKey}`,
...budgetKeys,
fileKey,
rawEntry,
entry.bytes,
limits.ttlSeconds
)) as [number, number]
return { claimed: Number(result[0]) === 1, deletedCount: Number(result[1] ?? 0) }
}
function stripBase64(file: UserFile): UserFile {
const { base64: _base64, ...rest } = file
return rest
}
async function resolveBase64(
file: UserFile,
options: Base64HydrationOptions,
logger: Logger
): Promise<string | null> {
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BASE64_BYTES
if (file.base64) {
const base64Bytes = Buffer.byteLength(file.base64, 'base64')
if (base64Bytes > maxBytes) {
logger.warn(
`[${options.requestId}] Skipping existing base64 for ${file.name} (decoded ${base64Bytes} exceeds ${maxBytes})`
)
return null
}
return file.base64
}
const allowUnknownSize = options.allowUnknownSize ?? false
const hasStableStorageKey = Boolean(file.key)
if (Number.isFinite(file.size) && file.size > maxBytes) {
logger.warn(
`[${options.requestId}] Skipping base64 for ${file.name} (size ${file.size} exceeds ${maxBytes})`
)
return null
}
if (
(!Number.isFinite(file.size) || file.size <= 0) &&
!allowUnknownSize &&
!hasStableStorageKey
) {
logger.warn(`[${options.requestId}] Skipping base64 for ${file.name} (unknown file size)`)
return null
}
const requestId = options.requestId ?? 'unknown'
try {
return await readUserFileContent(file, {
requestId,
workspaceId: options.workspaceId,
workflowId: options.workflowId,
executionId: options.executionId,
largeValueExecutionIds: options.largeValueExecutionIds,
fileKeys: options.fileKeys,
allowLargeValueWorkflowScope: options.allowLargeValueWorkflowScope,
userId: options.userId,
encoding: 'base64',
maxBytes,
maxSourceBytes: maxBytes,
})
} catch (error) {
logger.warn(`[${requestId}] Failed to hydrate base64 for ${file.name}`, error)
return null
}
}
async function hydrateUserFile(
file: UserFile,
options: Base64HydrationOptions,
state: HydrationState,
logger: Logger
): Promise<UserFile> {
if (!file.base64) {
try {
await assertUserFileContentAccess(file, {
requestId: options.requestId,
workspaceId: options.workspaceId,
workflowId: options.workflowId,
executionId: options.executionId,
largeValueExecutionIds: options.largeValueExecutionIds,
fileKeys: options.fileKeys,
allowLargeValueWorkflowScope: options.allowLargeValueWorkflowScope,
userId: options.userId,
logger,
})
} catch (error) {
logger.warn(`[${options.requestId ?? 'unknown'}] Skipping unauthorized file base64`, error)
return stripBase64(file)
}
}
const cached = await state.cache.get(file)
if (cached) {
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BASE64_BYTES
if (Buffer.byteLength(cached, 'base64') > maxBytes) {
return stripBase64(file)
}
return { ...file, base64: cached }
}
const base64 = await resolveBase64(file, options, logger)
if (!base64) {
return stripBase64(file)
}
await state.cache.set(file, base64, state.cacheTtlSeconds)
return { ...file, base64 }
}
async function hydrateValue(
value: unknown,
options: Base64HydrationOptions,
state: HydrationState,
logger: Logger
): Promise<unknown> {
if (!value || typeof value !== 'object') {
return value
}
if (
options.preserveLargeValueMetadata &&
(isLargeArrayManifest(value) || isLargeValueRef(value))
) {
return value
}
if (isLargeArrayManifest(value)) {
const materialized = await materializeLargeArrayManifest(value, options)
return hydrateValue(
materialized,
withLocalLargeValueExecutionIds(options, materialized),
state,
logger
)
}
if (isLargeValueRef(value)) {
const materialized = await materializeLargeValueRef(value, options)
if (materialized === undefined) {
throw getLargeValueMaterializationError(value)
}
return hydrateValue(
materialized,
withLocalLargeValueExecutionIds(options, materialized),
state,
logger
)
}
if (isUserFileWithMetadata(value)) {
return hydrateUserFile(value, options, state, logger)
}
if (state.seen.has(value)) {
return value
}
state.seen.add(value)
if (Array.isArray(value)) {
const hydratedItems = await Promise.all(
value.map((item) => hydrateValue(item, options, state, logger))
)
return hydratedItems
}
const entries = await Promise.all(
Object.entries(value).map(async ([key, entryValue]) => {
const hydratedEntry = await hydrateValue(entryValue, options, state, logger)
return [key, hydratedEntry] as const
})
)
return Object.fromEntries(entries)
}
function withLocalLargeValueExecutionIds(
options: Base64HydrationOptions,
materializedValue: unknown
): Base64HydrationOptions {
recordMaterializedAccessKeys(options, materializedValue)
return {
...options,
largeValueKeys: options.largeValueKeys,
fileKeys: options.fileKeys,
}
}
/**
* Hydrates UserFile objects within a value to include base64 content.
* Returns the original structure with UserFile.base64 set where available.
*/
export async function hydrateUserFilesWithBase64<T>(
value: T,
options: Base64HydrationOptions
): Promise<T> {
const logger = getHydrationLogger(options)
const state = createHydrationState(options, logger)
return (await hydrateValue(value, options, state, logger)) as T
}
/**
* Hydrates a single UserFile object when a resolver explicitly asks for base64.
*/
export async function hydrateUserFileWithBase64(
file: UserFile,
options: Base64HydrationOptions
): Promise<UserFile> {
const logger = getHydrationLogger(options)
const state = createHydrationState(options, logger)
return hydrateUserFile(file, options, state, logger)
}
/**
* Checks if a value contains any UserFile objects with metadata.
*/
export function containsUserFileWithMetadata(value: unknown): boolean {
if (!value || typeof value !== 'object') {
return false
}
if (isUserFileWithMetadata(value)) {
return true
}
if (Array.isArray(value)) {
return value.some((item) => containsUserFileWithMetadata(item))
}
if (!isPlainRecord(value)) {
return false
}
return Object.values(value).some((entry) => containsUserFileWithMetadata(entry))
}
/**
* Cleans up base64 cache entries for a specific execution.
* Should be called at the end of workflow execution.
*/
export async function cleanupExecutionBase64Cache(executionId: string): Promise<void> {
const redis = getRedisClient()
if (!redis) {
return
}
const logger = createLogger('UserFileBase64')
try {
const budgetEntries = await redis.hgetall(getBudgetIndexKey(executionId))
let deletedCount = 0
for (const [fileKey, rawEntry] of Object.entries(budgetEntries ?? {})) {
const budgetEntry = parseBudgetEntry(rawEntry)
if (!budgetEntry) continue
const cleanupResult = await cleanupBudgetEntry(
redis,
executionId,
fileKey,
rawEntry,
budgetEntry
)
if (cleanupResult.claimed) {
deletedCount += cleanupResult.deletedCount
}
}
if (deletedCount > 0) {
logger.info(`Cleaned up ${deletedCount} base64 cache entries for execution ${executionId}`)
}
} catch (error) {
logger.warn(`Failed to cleanup base64 cache for execution ${executionId}`, error)
}
}
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import {
SUPPORTED_ATTACHMENT_EXTENSIONS,
sniffImageContentType,
validateAttachmentFileType,
} from '@/lib/uploads/utils/validation'
describe('sniffImageContentType', () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00])
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00])
const gif87 = Buffer.from('GIF87a....', 'latin1')
const gif89 = Buffer.from('GIF89a....', 'latin1')
const webp = Buffer.concat([
Buffer.from('RIFF', 'latin1'),
Buffer.from([0x00, 0x00, 0x00, 0x00]),
Buffer.from('WEBP', 'latin1'),
])
it('detects real raster image formats from magic bytes', () => {
expect(sniffImageContentType(png)).toBe('image/png')
expect(sniffImageContentType(jpeg)).toBe('image/jpeg')
expect(sniffImageContentType(gif87)).toBe('image/gif')
expect(sniffImageContentType(gif89)).toBe('image/gif')
expect(sniffImageContentType(webp)).toBe('image/webp')
})
it('rejects non-image content, including image-shaped strings and SVG', () => {
expect(
sniffImageContentType(Buffer.from('<html><script>x</script></html>', 'utf-8'))
).toBeNull()
expect(sniffImageContentType(Buffer.from('<svg xmlns="...">', 'utf-8'))).toBeNull()
expect(sniffImageContentType(Buffer.from('RIFFxxxxAVI ', 'latin1'))).toBeNull()
expect(sniffImageContentType(Buffer.alloc(0))).toBeNull()
expect(sniffImageContentType(Buffer.from([0x89, 0x50]))).toBeNull()
})
})
describe('validateAttachmentFileType', () => {
it('accepts image files (png, jpg, gif, webp, svg)', () => {
expect(validateAttachmentFileType('screenshot.png')).toBeNull()
expect(validateAttachmentFileType('photo.jpg')).toBeNull()
expect(validateAttachmentFileType('photo.JPEG')).toBeNull()
expect(validateAttachmentFileType('animation.gif')).toBeNull()
expect(validateAttachmentFileType('image.webp')).toBeNull()
expect(validateAttachmentFileType('icon.svg')).toBeNull()
})
it('accepts video files (mp4, mov, webm)', () => {
expect(validateAttachmentFileType('clip.mp4')).toBeNull()
expect(validateAttachmentFileType('clip.mov')).toBeNull()
expect(validateAttachmentFileType('clip.webm')).toBeNull()
})
it('accepts audio files (mp3, wav, m4a)', () => {
expect(validateAttachmentFileType('voice.mp3')).toBeNull()
expect(validateAttachmentFileType('voice.wav')).toBeNull()
expect(validateAttachmentFileType('voice.m4a')).toBeNull()
})
it('accepts document files (pdf, docx, csv, md)', () => {
expect(validateAttachmentFileType('report.pdf')).toBeNull()
expect(validateAttachmentFileType('letter.docx')).toBeNull()
expect(validateAttachmentFileType('data.csv')).toBeNull()
expect(validateAttachmentFileType('notes.md')).toBeNull()
})
it('accepts code files (ts, py, sh, json)', () => {
expect(validateAttachmentFileType('app.ts')).toBeNull()
expect(validateAttachmentFileType('main.py')).toBeNull()
expect(validateAttachmentFileType('script.sh')).toBeNull()
expect(validateAttachmentFileType('config.json')).toBeNull()
})
it('rejects executables and unknown extensions', () => {
expect(validateAttachmentFileType('virus.exe')?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(validateAttachmentFileType('installer.msi')?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(validateAttachmentFileType('archive.dmg')?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(validateAttachmentFileType('binary.bin')?.code).toBe('UNSUPPORTED_FILE_TYPE')
})
it('rejects files with no extension', () => {
const result = validateAttachmentFileType('README')
expect(result?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(result?.message).toContain('README')
})
it('rejects files with non-alphanumeric extensions', () => {
expect(validateAttachmentFileType('odd.<>')?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(validateAttachmentFileType('foo. ')?.code).toBe('UNSUPPORTED_FILE_TYPE')
})
it('does not contain duplicate extensions (e.g. webm)', () => {
const seen = new Set<string>()
for (const ext of SUPPORTED_ATTACHMENT_EXTENSIONS) {
expect(seen.has(ext)).toBe(false)
seen.add(ext)
}
})
it('returns supportedTypes list in error', () => {
const result = validateAttachmentFileType('foo.exe')
expect(result?.supportedTypes).toEqual(expect.arrayContaining(['png', 'pdf', 'mp4', 'mp3']))
})
})
+398
View File
@@ -0,0 +1,398 @@
/**
* Checks whether a string is a valid file extension (lowercase alphanumeric only).
* Rejects extensions containing spaces, punctuation, or other non-alphanumeric characters
* that arise from non-filename document names (e.g. "Sim.ai <> RVTech").
*/
export function isAlphanumericExtension(ext: string): boolean {
return /^[a-z0-9]+$/.test(ext)
}
function extractExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.')
return lastDot !== -1 ? fileName.slice(lastDot + 1).toLowerCase() : ''
}
export const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB
export const SUPPORTED_DOCUMENT_EXTENSIONS = [
'pdf',
'csv',
'doc',
'docx',
'txt',
'md',
'xlsx',
'xls',
'ppt',
'pptx',
'html',
'htm',
'json',
'jsonl',
'yaml',
'yml',
] as const
export const SUPPORTED_CODE_EXTENSIONS = [
'mdx',
'xml',
'css',
'scss',
'less',
'js',
'jsx',
'ts',
'tsx',
'py',
'rb',
'go',
'rs',
'java',
'kt',
'swift',
'c',
'cpp',
'h',
'hpp',
'cs',
'php',
'sh',
'bash',
'zsh',
'fish',
'sql',
'graphql',
'gql',
'toml',
'ini',
'conf',
'cfg',
'env',
'log',
'diff',
'patch',
'dockerfile',
'makefile',
'gitignore',
'editorconfig',
'prettierrc',
'eslintrc',
'mmd',
] as const
export type SupportedCodeExtension = (typeof SUPPORTED_CODE_EXTENSIONS)[number]
export const SUPPORTED_AUDIO_EXTENSIONS = [
'mp3',
'm4a',
'wav',
'webm',
'ogg',
'flac',
'aac',
'opus',
] as const
export const SUPPORTED_VIDEO_EXTENSIONS = ['mp4', 'mov', 'avi', 'mkv', 'webm'] as const
export const SUPPORTED_IMAGE_EXTENSIONS = [
'png',
'jpg',
'jpeg',
'gif',
'webp',
'svg',
'bmp',
'tif',
'tiff',
'heic',
'heif',
'avif',
'ico',
] as const
export type SupportedDocumentExtension = (typeof SUPPORTED_DOCUMENT_EXTENSIONS)[number]
export type SupportedAudioExtension = (typeof SUPPORTED_AUDIO_EXTENSIONS)[number]
export type SupportedVideoExtension = (typeof SUPPORTED_VIDEO_EXTENSIONS)[number]
export type SupportedImageExtension = (typeof SUPPORTED_IMAGE_EXTENSIONS)[number]
export type SupportedMediaExtension =
| SupportedDocumentExtension
| SupportedAudioExtension
| SupportedVideoExtension
| SupportedImageExtension
export const SUPPORTED_MIME_TYPES: Record<SupportedDocumentExtension, string[]> = {
pdf: ['application/pdf', 'application/x-pdf'],
csv: ['text/csv', 'application/csv', 'text/comma-separated-values'],
doc: ['application/msword', 'application/doc', 'application/vnd.ms-word'],
docx: [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/octet-stream',
],
txt: ['text/plain', 'text/x-plain', 'application/txt'],
md: [
'text/markdown',
'text/x-markdown',
'text/plain',
'application/markdown',
'application/octet-stream',
],
xlsx: [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/octet-stream',
],
xls: [
'application/vnd.ms-excel',
'application/excel',
'application/x-excel',
'application/x-msexcel',
],
ppt: ['application/vnd.ms-powerpoint', 'application/powerpoint', 'application/x-mspowerpoint'],
pptx: [
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/octet-stream',
],
html: ['text/html', 'application/xhtml+xml'],
htm: ['text/html', 'application/xhtml+xml'],
json: ['application/json', 'text/json', 'application/x-json'],
jsonl: ['application/jsonl', 'application/x-jsonlines', 'text/jsonl', 'application/octet-stream'],
yaml: ['text/yaml', 'text/x-yaml', 'application/yaml', 'application/x-yaml'],
yml: ['text/yaml', 'text/x-yaml', 'application/yaml', 'application/x-yaml'],
}
export const SUPPORTED_AUDIO_MIME_TYPES: Record<SupportedAudioExtension, string[]> = {
mp3: ['audio/mpeg', 'audio/mp3'],
m4a: ['audio/mp4', 'audio/x-m4a', 'audio/m4a'],
wav: ['audio/wav', 'audio/wave', 'audio/x-wav'],
webm: ['audio/webm'],
ogg: ['audio/ogg', 'audio/vorbis'],
flac: ['audio/flac', 'audio/x-flac'],
aac: ['audio/aac', 'audio/x-aac'],
opus: ['audio/opus'],
}
export const SUPPORTED_VIDEO_MIME_TYPES: Record<SupportedVideoExtension, string[]> = {
mp4: ['video/mp4', 'video/mpeg'],
mov: ['video/quicktime', 'video/x-quicktime'],
avi: ['video/x-msvideo', 'video/avi'],
mkv: ['video/x-matroska'],
webm: ['video/webm'],
}
export const ACCEPTED_FILE_TYPES = Object.values(SUPPORTED_MIME_TYPES).flat()
export const ACCEPTED_AUDIO_TYPES = Object.values(SUPPORTED_AUDIO_MIME_TYPES).flat()
export const ACCEPTED_VIDEO_TYPES = Object.values(SUPPORTED_VIDEO_MIME_TYPES).flat()
export const ACCEPTED_MEDIA_TYPES = [
...ACCEPTED_FILE_TYPES,
...ACCEPTED_AUDIO_TYPES,
...ACCEPTED_VIDEO_TYPES,
]
export const ACCEPTED_FILE_EXTENSIONS = SUPPORTED_DOCUMENT_EXTENSIONS.map((ext) => `.${ext}`)
export const ACCEPT_ATTRIBUTE = [...ACCEPTED_FILE_TYPES, ...ACCEPTED_FILE_EXTENSIONS].join(',')
const SUPPORTED_IMAGE_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
'image/bmp',
'image/tiff',
'image/heic',
'image/heif',
'image/avif',
'image/x-icon',
'image/vnd.microsoft.icon',
]
export const CHAT_ACCEPT_ATTRIBUTE = [
ACCEPT_ATTRIBUTE,
...SUPPORTED_IMAGE_MIME_TYPES,
...SUPPORTED_IMAGE_EXTENSIONS.map((ext) => `.${ext}`),
].join(',')
export interface FileValidationError {
code: 'UNSUPPORTED_FILE_TYPE' | 'MIME_TYPE_MISMATCH'
message: string
supportedTypes: string[]
}
export const SUPPORTED_ATTACHMENT_EXTENSIONS = Array.from(
new Set<string>([
...SUPPORTED_DOCUMENT_EXTENSIONS,
...SUPPORTED_CODE_EXTENSIONS,
...SUPPORTED_IMAGE_EXTENSIONS,
...SUPPORTED_AUDIO_EXTENSIONS,
...SUPPORTED_VIDEO_EXTENSIONS,
])
) as readonly string[]
/**
* Validate that a file's extension is allowed as a chat/mothership attachment.
*
* Permits documents, code, images, audio, and video — anything users would
* reasonably attach to a chat message. Rejects executables and unknown types.
*/
export function validateAttachmentFileType(fileName: string): FileValidationError | null {
const raw = extractExtension(fileName)
const extension = isAlphanumericExtension(raw) ? raw : ''
if (!SUPPORTED_ATTACHMENT_EXTENSIONS.includes(extension)) {
return {
code: 'UNSUPPORTED_FILE_TYPE',
message: `Unsupported file type${extension ? `: ${extension}` : ` for "${fileName}"`}. Supported types include documents, code, images, audio, and video.`,
supportedTypes: [...SUPPORTED_ATTACHMENT_EXTENSIONS],
}
}
return null
}
/**
* Validate if a file type is supported for document processing
*/
export function validateFileType(fileName: string, mimeType: string): FileValidationError | null {
const raw = extractExtension(fileName)
const extension = (isAlphanumericExtension(raw) ? raw : '') as SupportedDocumentExtension
if (!SUPPORTED_DOCUMENT_EXTENSIONS.includes(extension)) {
return {
code: 'UNSUPPORTED_FILE_TYPE',
message: `Unsupported file type${extension ? `: ${extension}` : ` for "${fileName}"`}. Supported types are: ${SUPPORTED_DOCUMENT_EXTENSIONS.join(', ')}`,
supportedTypes: [...SUPPORTED_DOCUMENT_EXTENSIONS],
}
}
const baseMimeType = mimeType.split(';')[0].trim()
// Allow empty MIME types if the extension is supported (browsers often don't recognize certain file types)
if (!baseMimeType) {
return null
}
const allowedMimeTypes = SUPPORTED_MIME_TYPES[extension]
if (!allowedMimeTypes.includes(baseMimeType)) {
return {
code: 'MIME_TYPE_MISMATCH',
message: `MIME type ${baseMimeType} does not match file extension ${extension}. Expected: ${allowedMimeTypes.join(', ')}`,
supportedTypes: allowedMimeTypes,
}
}
return null
}
/**
* Check if file extension is supported
*/
export function isSupportedExtension(extension: string): extension is SupportedDocumentExtension {
return SUPPORTED_DOCUMENT_EXTENSIONS.includes(
extension.toLowerCase() as SupportedDocumentExtension
)
}
/**
* Get supported MIME types for an extension
*/
export function getSupportedMimeTypes(extension: string): string[] {
if (isSupportedExtension(extension)) {
return SUPPORTED_MIME_TYPES[extension as SupportedDocumentExtension]
}
if (SUPPORTED_AUDIO_EXTENSIONS.includes(extension as SupportedAudioExtension)) {
return SUPPORTED_AUDIO_MIME_TYPES[extension as SupportedAudioExtension]
}
if (SUPPORTED_VIDEO_EXTENSIONS.includes(extension as SupportedVideoExtension)) {
return SUPPORTED_VIDEO_MIME_TYPES[extension as SupportedVideoExtension]
}
return []
}
/**
* Check if file extension is a supported audio extension
*/
export function isSupportedAudioExtension(extension: string): extension is SupportedAudioExtension {
return SUPPORTED_AUDIO_EXTENSIONS.includes(extension.toLowerCase() as SupportedAudioExtension)
}
/**
* Check if file extension is a supported video extension
*/
export function isSupportedVideoExtension(extension: string): extension is SupportedVideoExtension {
return SUPPORTED_VIDEO_EXTENSIONS.includes(extension.toLowerCase() as SupportedVideoExtension)
}
/**
* Validate if an audio/video file type is supported for STT processing
*/
const PNG_MAGIC_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
/**
* Validate that a buffer contains valid PNG data by checking magic bytes
*/
export function isValidPng(buffer: Buffer): boolean {
return buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_MAGIC_BYTES)
}
/**
* Detect a renderable raster image from its leading bytes, returning the canonical MIME type or
* `null` when the content is not one of the inline-renderable image formats (PNG, JPEG, GIF, WebP).
*
* The stored `contentType` is client-declared and never sniffed at upload time, so any path that
* renders a file inline for a less-trusted audience (e.g. images embedded in a public share) must
* derive the served type from the bytes themselves — a file claiming `image/png` could be HTML, SVG,
* or a script. SVG is deliberately excluded: it can carry script and is not a raster format.
*/
export function sniffImageContentType(buffer: Buffer): string | null {
if (isValidPng(buffer)) return 'image/png'
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return 'image/jpeg'
}
if (buffer.length >= 6) {
const header = buffer.toString('latin1', 0, 6)
if (header === 'GIF87a' || header === 'GIF89a') return 'image/gif'
}
if (
buffer.length >= 12 &&
buffer.toString('latin1', 0, 4) === 'RIFF' &&
buffer.toString('latin1', 8, 12) === 'WEBP'
) {
return 'image/webp'
}
return null
}
export function validateMediaFileType(
fileName: string,
mimeType: string
): FileValidationError | null {
const raw = extractExtension(fileName)
const extension = isAlphanumericExtension(raw) ? raw : ''
const isAudio = SUPPORTED_AUDIO_EXTENSIONS.includes(extension as SupportedAudioExtension)
const isVideo = SUPPORTED_VIDEO_EXTENSIONS.includes(extension as SupportedVideoExtension)
if (!isAudio && !isVideo) {
return {
code: 'UNSUPPORTED_FILE_TYPE',
message: `Unsupported media file type${extension ? `: ${extension}` : ` for "${fileName}"`}. Supported audio types: ${SUPPORTED_AUDIO_EXTENSIONS.join(', ')}. Supported video types: ${SUPPORTED_VIDEO_EXTENSIONS.join(', ')}`,
supportedTypes: [...SUPPORTED_AUDIO_EXTENSIONS, ...SUPPORTED_VIDEO_EXTENSIONS],
}
}
const baseMimeType = mimeType.split(';')[0].trim()
const allowedMimeTypes = isAudio
? SUPPORTED_AUDIO_MIME_TYPES[extension as SupportedAudioExtension]
: SUPPORTED_VIDEO_MIME_TYPES[extension as SupportedVideoExtension]
if (!allowedMimeTypes.includes(baseMimeType)) {
return {
code: 'MIME_TYPE_MISMATCH',
message: `MIME type ${baseMimeType} does not match file extension ${extension}. Expected: ${allowedMimeTypes.join(', ')}`,
supportedTypes: allowedMimeTypes,
}
}
return null
}