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
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:
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user