@cloudbase/storage
Version:
cloudbase js sdk storage module
747 lines (658 loc) • 21.4 kB
text/typescript
import { constants } from '@cloudbase/utilities'
import { CloudbaseStorage, COMPONENT_NAME, ICloudbaseContext } from '../storage'
import { Camelize, FileBody, FileObject, FileObjectV2, FileOptions, TransformOptions } from './types'
import { isStorageError, StorageError } from './errors'
const { ERRORS } = constants
export class SupabaseFileAPILikeStorage extends CloudbaseStorage {
private shouldThrowOnError = false
private bucketId = ''
private context: ICloudbaseContext
constructor(context?: ICloudbaseContext) {
super()
this.context = context
}
get config() {
// @ts-ignore
return this.context?.config
}
get request() {
// @ts-ignore
return this.context?.request
}
throwOnError(): this {
this.shouldThrowOnError = true
return this
}
from(bucket?: string) {
this.bucketId = bucket || ''
return this
}
async upload(
path: string,
fileBody: FileBody,
fileOptions?: FileOptions,
): Promise<
{ data: { id: string; path: string; fullPath: string }; error: null } | { data: null; error: StorageError }
> {
const options = { upsert: true, ...fileOptions }
const { cacheControl, contentType, metadata } = options
try {
const cloudPath = this._getCloudPath(path)
const uploadFileParams: Parameters<CloudbaseStorage['uploadFile']>[0] = {
cloudPath,
filePath: fileBody as any,
}
if (cacheControl || contentType || metadata) {
const headers = {}
if (cacheControl) {
headers['cache-control'] = cacheControl
}
if (contentType) {
headers['content-type'] = contentType
}
if (metadata) {
headers['x-cos-metadata-metadata'] = this.toBase64(JSON.stringify(metadata))
}
uploadFileParams.headers = headers
}
const result = await this.uploadFile(uploadFileParams)
// IUploadFileRes 没有 code 字段,如果没有 fileID 则表示失败
if (!result.fileID) {
throw new Error(JSON.stringify({
code: ERRORS.OPERATION_FAIL,
msg: `[${COMPONENT_NAME}.update] no fileID returned`,
}),)
}
return {
data: {
id: result.fileID,
path,
fullPath: path,
},
error: null,
}
} catch (error: any) {
if (this.shouldThrowOnError) throw error
if (isStorageError(error)) {
return {
data: null,
error,
}
}
throw error
}
}
async uploadToSignedUrl(
path: string,
_token: string,
fileBody: FileBody,
fileOptions?: FileOptions,
): Promise<
{ data: { id: string; path: string; fullPath: string }; error: null } | { data: null; error: StorageError }
> {
return this.upload(path, fileBody, fileOptions)
}
async createSignedUploadUrl(path: string): Promise<
| {
data: {
signedUrl: string
token: string
path: string
// CloudBase 额外的元数据字段
authorization?: string
id?: string
cosFileId?: string
downloadUrl?: string
}
error: null
}
| { data: null; error: StorageError }
> {
try {
const cloudPath = this._getCloudPath(path)
const { data: metadata } = await this.getUploadMetadata({ cloudPath })
return {
data: {
signedUrl: metadata.url,
token: metadata.token,
path,
// 返回 CloudBase 的额外元数据,供 uploadToSignedUrl 使用
authorization: metadata.authorization,
id: metadata.fileId,
cosFileId: metadata.cosFileId,
downloadUrl: metadata.download_url,
},
error: null,
}
} catch (error: any) {
if (this.shouldThrowOnError) throw error
return {
data: null,
error: error instanceof StorageError ? error : new StorageError(error.message),
}
}
}
async update(
path: string,
fileBody: FileBody,
fileOptions?: FileOptions,
): Promise<
{ data: { id: string; path: string; fullPath: string }; error: null } | { data: null; error: StorageError }
> {
return this.upload(path, fileBody, { ...fileOptions, upsert: true })
}
async move(
fromPath: string,
toPath: string,
// options?: DestinationOptions,
): Promise<{ data: { message: string }; error: null } | { data: null; error: StorageError }> {
try {
const result = await this.copyFile({
fileList: [
{
srcPath: this._getCloudPath(fromPath),
dstPath: this._getCloudPath(toPath),
overwrite: true,
removeOriginal: true,
},
],
})
if (result.fileList[0].code && result.fileList[0].code !== 'SUCCESS') {
throw new StorageError(result.fileList[0].message || 'Move failed')
}
return {
data: { message: `File moved from ${fromPath} to ${toPath}` },
error: null,
}
} catch (error: any) {
if (this.shouldThrowOnError) throw error
if (isStorageError(error)) {
return {
data: null,
error,
}
}
throw error
}
}
async copy(
fromPath: string,
toPath: string,
// options?: DestinationOptions,
): Promise<{ data: { path: string }; error: null } | { data: null; error: StorageError }> {
try {
const result = await this.copyFile({
fileList: [
{
srcPath: this._getCloudPath(fromPath),
dstPath: this._getCloudPath(toPath),
overwrite: true,
removeOriginal: false,
},
],
})
if (result.fileList[0].code && result.fileList[0].code !== 'SUCCESS') {
throw new StorageError(result.fileList[0].message || 'Copy failed')
}
return {
data: { path: this._getCloudPath(toPath) },
error: null,
}
} catch (error: any) {
if (this.shouldThrowOnError) throw error
if (isStorageError(error)) {
return { data: null, error }
}
throw error
}
}
async createSignedUrl(
path: string,
expiresIn: number,
options?: {
download?: string | boolean
transform?: TransformOptions
},
): Promise<{ data: { signedUrl: string }; error: null } | { data: null; error: StorageError }> {
try {
const cloudPath = this._normalizeCloudId(path)
const result = await this.getTempFileURL({
fileList: [
{
fileID: cloudPath,
maxAge: expiresIn,
},
],
})
// IGetFileUrlItem 有 code 字段但没有 message 字段
if (result.fileList[0].code !== 'SUCCESS') {
throw new StorageError(`Failed to create signed URL: [${result.fileList[0].code}] ${result.fileList[0].fileID}`)
}
let signedUrl = result.fileList[0].download_url
// 构建查询参数
const queryParams: string[] = []
// 如果有 download 参数,添加到 URL 中
if (options?.download !== undefined) {
if (typeof options.download === 'string') {
// download 是文件名
queryParams.push(`download=${encodeURIComponent(options.download)}`)
} else if (options.download === true) {
// download 是 true,使用原文件名或默认值
queryParams.push('download=true')
}
}
// 如果有图片转换参数,添加到 URL 中
if (options?.transform) {
const transformQuery = this._transformOptsToQueryString(options.transform)
if (transformQuery) {
queryParams.push(transformQuery)
}
}
// 拼接所有查询参数
if (queryParams.length > 0) {
const separator = signedUrl.includes('?') ? '&' : '?'
signedUrl = `${signedUrl}${separator}${queryParams.join('&')}`
}
return {
data: { signedUrl },
error: null,
}
} catch (error: any) {
if (this.shouldThrowOnError) throw error
if (isStorageError(error)) {
return {
data: null,
error,
}
}
throw error
}
}
async createSignedUrls(
paths: string[],
expiresIn: number,
// options?: {
// download?: string | boolean
// },
): Promise<
| { data: Array<{ path: string; signedUrl: string; error: string | null }>; error: null }
| { data: null; error: StorageError }
> {
try {
const fileList = paths.map(p => ({
fileID: this._normalizeCloudId(p),
maxAge: expiresIn,
}))
const result = await this.getTempFileURL({ fileList })
return {
data: result.fileList.map((item: any, index: number) => ({
path: paths[index],
signedUrl: item.tempFileURL || '',
error: item.code === 'SUCCESS' ? null : item.message,
})),
error: null,
}
} catch (error: any) {
if (this.shouldThrowOnError) throw error
if (isStorageError(error)) {
return {
data: null,
error,
}
}
throw error
}
}
async download(
path: string,
options?: TransformOptions,
): Promise<{
data: Blob
error: StorageError | null
}> {
try {
return {
data: await (async () => {
const signedUrlResult = await this.createSignedUrl(path, 600, { transform: options })
if (signedUrlResult.error) {
throw signedUrlResult.error
}
const tmpUrl = encodeURI(signedUrlResult.data?.signedUrl)
const { data } = await (this as any).request.reqClass.get({
url: tmpUrl,
headers: {}, // 下载资源请求不经过service,header清空
responseType: 'blob',
})
if (!data) {
throw new StorageError('Download failed: no file content')
}
// 将 Buffer 转换为 Uint8Array 以兼容 Blob
return new Blob([data])
})(),
error: null,
}
} catch (error: any) {
if (this.shouldThrowOnError) throw error
if (isStorageError(error)) {
return {
data: null,
error,
}
}
throw error
}
}
/**
* 获取文件信息
*
* @param pathOrFileId - 相对路径(如 'images/photo.jpg')或 CloudBase fileID(以 'cloud://' 开头)
* @returns 文件信息对象
*
* @example
* ```typescript
* // 使用相对路径
* const { data } = await bucket.info('images/photo.jpg')
*
* // 使用 CloudBase fileID
* const { data } = await bucket.info('cloud://env-id.xxxx-xxxx/images/photo.jpg')
* ```
*/
async info(pathOrFileId: string,): Promise<{ data: Camelize<FileObjectV2>; error: null } | { data: null; error: StorageError }> {
try {
// 判断是 fileID 还是相对路径
const isFileId = pathOrFileId.startsWith('cloud://')
const displayName = isFileId ? this._extractPathFromFileId(pathOrFileId) : pathOrFileId
const bucketId = isFileId ? this._extractBucketFromFileId(pathOrFileId) : this.bucketId
const fileInfo = await this.getFileInfo({
fileList: [this._normalizeCloudId(pathOrFileId)],
})
const item = fileInfo.fileList[0]
if (item.code !== 'SUCCESS') {
throw new StorageError(item.message)
}
const now = new Date().toISOString()
const lastModified = (item.lastModified ? new Date(item.lastModified) : new Date()).toISOString()
return {
data: {
id: item.fileID,
version: '1',
name: displayName,
bucketId,
updatedAt: lastModified,
createdAt: lastModified,
lastAccessedAt: now,
size: item.size,
cacheControl: item.cacheControl,
contentType: item.contentType,
etag: item.etag,
lastModified,
metadata: {},
},
error: null,
}
} catch (error: any) {
if (this.shouldThrowOnError) throw error
return {
data: null,
error: error instanceof StorageError ? error : new StorageError(error.message),
}
}
}
async exists(pathOrFileId: string): Promise<{ data: boolean; error: null } | { data: null; error: StorageError }> {
try {
// 判断是 fileID 还是相对路径
const fileInfo = await this.getFileInfo({
fileList: [this._normalizeCloudId(pathOrFileId)],
})
const item = fileInfo.fileList[0]
if (item.code === 'FILE_NOT_FOUND') {
return {
data: false,
error: null,
}
}
if (item.code !== 'SUCCESS') {
throw new StorageError(item.message)
}
return { data: true, error: null }
} catch (error: any) {
if (this.shouldThrowOnError) throw error
throw error
}
}
async getPublicUrl(
path: string,
options?: {
download?: string | boolean
transform?: TransformOptions
},
): Promise<
| {
data: { publicUrl: string }
}
| { data: null; error: StorageError }
> {
const res = await this.createSignedUrl(path, 600, options)
if (res.data) {
return {
data: { publicUrl: res.data.signedUrl },
}
}
return { data: null, error: res.error as StorageError }
}
async remove(paths: string[]): Promise<{ data: FileObject[]; error: null } | { data: null; error: StorageError }> {
try {
// 分组获取文件信息,每组最多10个
const chunkSize = 10
const pathChunks: string[][] = []
for (let i = 0; i < paths.length; i += chunkSize) {
pathChunks.push(paths.slice(i, i + chunkSize))
}
// 并行获取所有分组的文件信息
const fileInfoResults = await Promise.all(pathChunks.map(chunk => Promise.all(chunk.map(path => this.info(path)))),)
// 合并所有文件信息并构建映射表(path -> fileInfo)
const fileInfoMap = new Map<string, Camelize<FileObjectV2>>()
fileInfoResults.flat().forEach((result, index) => {
if (result.data) {
fileInfoMap.set(paths[Math.floor(index / chunkSize) * chunkSize + (index % chunkSize)], result.data)
}
})
// 执行删除操作
const fileList = paths.map(p => this._normalizeCloudId(p))
const result = await this.deleteFile({ fileList })
// IDeleteFileRes 的 fileList 数组中每个项有 code 字段
const failedFiles = result.fileList.filter(item => item.code !== 'SUCCESS')
if (failedFiles.length > 0) {
throw new StorageError(`Delete failed for ${failedFiles.length} file(s)`)
}
const now = new Date().toISOString()
// 使用获取到的文件信息构建返回数据
return {
data: paths.map((p) => {
const info = fileInfoMap.get(p)
return {
name: info?.name,
id: info?.id,
bucket_id: info?.bucketId,
owner: undefined, // 无法计算owner
updated_at: info?.updatedAt || now,
created_at: info?.createdAt,
last_accessed_at: info?.lastAccessedAt || now,
metadata: info?.metadata || {},
/**
* TODO: 获取补全 Bucket 信息
*/
buckets: {
id: info?.bucketId,
name: info?.bucketId,
owner: undefined, // 无法计算owner
public: false, // 未知
created_at: '', // 未知
updated_at: now, // 未知
},
}
}),
error: null,
}
} catch (error: any) {
if (this.shouldThrowOnError) throw error
if (isStorageError(error)) {
return {
data: null,
error,
}
}
throw error
}
}
async list() {
throw new StorageError('Not implemented')
}
private _getCloudPath(path: string): string {
// 清理路径:移除首尾斜杠,合并多个斜杠
const cleanPath = path.replace(/^\/|\/$/g, '').replace(/\/+/g, '/')
// 否则返回清理后的相对路径
return cleanPath
}
private _normalizeCloudId(path: string) {
// 如果已经是 cloud:// 格式,直接返回
if (/^cloud:\/\//.test(path)) {
return path
}
const cleanPath = this._getCloudPath(path)
// 如果设置了 bucketId,构建完整的 cloud:// 路径
if (this.bucketId) {
// 获取环境 ID
const envId = this.config?.env || ''
if (envId) {
return `cloud://${envId}.${this.bucketId}/${cleanPath}`
}
} else {
throw new StorageError('bucketId is not set')
}
}
private toBase64(data: string) {
if (typeof Buffer !== 'undefined') {
return Buffer.from(data).toString('base64')
}
return btoa(data)
}
/**
* 将 TransformOptions 转换为腾讯云数据万象的 imageMogr2 查询字符串
*
* 腾讯云数据万象使用 imageMogr2 接口进行图片处理,支持以下参数:
* - /thumbnail/<Width>x<Height> - 缩放
* - /format/<Format> - 格式转换
* - /quality/<Quality> - 质量调整
* - /rquality/<Quality> - 相对质量
*
* @param transform - Supabase TransformOptions
* @returns 腾讯云数据万象的查询字符串
*
* @example
* ```typescript
* _transformOptsToQueryString({ width: 300, height: 200, quality: 80, format: 'origin' })
* // 返回: 'imageMogr2/thumbnail/300x200/quality/80'
* ```
*/
private _transformOptsToQueryString(transform: TransformOptions): string {
const params: string[] = ['imageMogr2']
// 处理缩放参数
if (transform.width || transform.height) {
const width = transform.width || ''
const height = transform.height || ''
// 根据 resize 模式选择不同的缩放方式
if (transform.resize === 'fill') {
// fill: 强制缩放到指定尺寸,可能变形
params.push(`thumbnail/${width}x${height}!`)
} else if (transform.resize === 'contain') {
// contain: 等比缩放,完整显示在指定尺寸内
params.push(`thumbnail/${width}x${height}`)
} else {
// cover (默认): 等比缩放,填充指定尺寸,可能裁剪
// 使用 /thumbnail/<Width>x<Height>^ 表示填充模式
params.push(`thumbnail/${width}x${height}^`)
}
}
// 处理格式转换
// format: 'origin' 表示保持原格式,不传此参数
if (transform.format && transform.format !== 'origin') {
params.push(`format/${transform.format}`)
}
// 处理质量参数
if (transform.quality !== undefined) {
// 腾讯云数据万象的 quality 参数范围是 1-100
const quality = Math.max(1, Math.min(100, transform.quality))
params.push(`quality/${quality}`)
}
return params.join('/')
}
/**
* 从 CloudBase fileID 中提取文件路径
*
* @param fileId - CloudBase fileID (格式: cloud://env-id.bucket/path/to/file.jpg)
* @returns 提取的文件路径
*
* @example
* ```typescript
* _extractPathFromFileId('cloud://test-env.test-bucket/images/photo.jpg')
* // 返回: 'images/photo.jpg'
*
* _extractPathFromFileId('cloud://test-env.test-bucket/file.txt')
* // 返回: 'file.txt'
* ```
*/
private _extractPathFromFileId(fileId: string): string {
// fileID 格式: cloud://env-id.bucket/path/to/file.jpg
// 需要提取第一个 / 后面的所有内容(包括 bucket 和路径)
// 然后再提取 bucket 后面的路径部分
// 移除 cloud:// 前缀
const withoutProtocol = fileId.replace(/^cloud:\/\//, '')
// 分割为 [env-id.bucket, path, to, file.jpg]
const parts = withoutProtocol.split('/')
if (parts.length < 2) {
// 如果无法解析,返回完整的 fileID
return fileId
}
// 移除第一部分(env-id.bucket),返回剩余路径
return parts.slice(1).join('/')
}
/**
* 从 CloudBase fileID 中提取 bucket 名称
*
* @param fileId - CloudBase fileID (格式: cloud://env-id.bucket/path/to/file.jpg)
* @returns 提取的 bucket 名称
*
* @example
* ```typescript
* _extractBucketFromFileId('cloud://test-env.test-bucket/images/photo.jpg')
* // 返回: 'test-bucket'
*
* _extractBucketFromFileId('cloud://prod-env.my-bucket/file.txt')
* // 返回: 'my-bucket'
* ```
*/
private _extractBucketFromFileId(fileId: string): string {
// fileID 格式: cloud://env-id.bucket/path/to/file.jpg
// 需要提取 env-id.bucket 中的 bucket 部分
// 移除 cloud:// 前缀
const withoutProtocol = fileId.replace(/^cloud:\/\//, '')
// 分割为 [env-id.bucket, path, to, file.jpg]
const parts = withoutProtocol.split('/')
if (parts.length < 1) {
// 如果无法解析,返回默认 bucket
return ''
}
// 第一部分是 env-id.bucket,提取 bucket 部分
// 格式: env-id.bucket 或 env-id.bucket-name
const envAndBucket = parts[0]
// 查找第一个点的位置
const dotIndex = envAndBucket.indexOf('.')
if (dotIndex === -1) {
// 如果没有点,返回整个字符串
return envAndBucket
}
// 返回点后面的部分(bucket 名称)
return envAndBucket.substring(dotIndex + 1)
}
}