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