UNPKG

@cloudbase/storage

Version:
747 lines (658 loc) 21.4 kB
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) } }