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,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
}