chore(build): 优化依赖和配置文件管理
- .gitignore 中新增 .env 文件忽略规则并保留示例文件 - package.json 中新增 src 文件夹纳入发布文件范围 - 升级 devDependencies 中部分包版本并新增 AWS SDK 和 es-toolkit 等依赖 - 移除 lodash 改用 es-toolkit 的 omit 函数以减小包体积 - 使用 dotenv 的解构导入替换默认导入,规范配置加载方式 - util 模块导出新增 extract-standard-headers 功能模块接口
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
S3_ACCESS_KEY_ID=your_access_key_id
|
||||
S3_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
S3_REGION=your_region
|
||||
S3_BUCKET_NAME=your_bucket_name
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.turbo
|
||||
.turbo
|
||||
|
||||
.env
|
||||
!.env*example
|
||||
21
package.json
21
package.json
@@ -6,19 +6,22 @@
|
||||
"build": "bun run bun.config.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"keywords": [],
|
||||
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.3",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/node": "^25.0.3",
|
||||
"bun-plugin-dts": "^0.3.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"minio": "^8.0.5",
|
||||
"tsup": "^8.4.0"
|
||||
"dotenv": "^17.2.3",
|
||||
"minio": "^8.0.6",
|
||||
"@aws-sdk/client-s3": "^3.0.0",
|
||||
"es-toolkit": "^1.43.0",
|
||||
"fast-glob": "^3.3.3"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -37,9 +40,5 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/lodash": "^4.17.21",
|
||||
"fast-glob": "^3.3.3",
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
"dependencies": {}
|
||||
}
|
||||
2069
pnpm-lock.yaml
generated
Normal file
2069
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,8 @@ import { Client, ItemBucketMetadata } from 'minio';
|
||||
import { ListFileObject, ListObjectResult, OssBaseOperation } from './core/type.ts';
|
||||
import { hash } from './util/hash.ts';
|
||||
import { copyObject } from './core/copy-object.ts';
|
||||
import omit from 'lodash/omit.js';
|
||||
import { getContentType } from './util/get-content-type.ts';
|
||||
import { omit } from 'es-toolkit'
|
||||
export type OssBaseOptions<T = { [key: string]: any }> = {
|
||||
/**
|
||||
* 已经初始化好的minio client
|
||||
|
||||
46
src/s3/copy-object.ts
Normal file
46
src/s3/copy-object.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { S3Client, CopyObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { extractStandardHeaders } from '../util/extract-standard-headers.ts';
|
||||
|
||||
type CopyObjectOpts = {
|
||||
bucketName: string;
|
||||
newMetadata: Record<string, string>;
|
||||
objectName: string;
|
||||
client: S3Client;
|
||||
};
|
||||
|
||||
/**
|
||||
* 复制对象 REPLACE 替换(使用 AWS SDK 实现)
|
||||
* @param opts 复制选项
|
||||
* @returns 复制结果
|
||||
*/
|
||||
export const copyObject = async ({ bucketName, newMetadata, objectName, client }: CopyObjectOpts) => {
|
||||
// 获取当前对象的元数据
|
||||
const headCommand = new HeadObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: objectName,
|
||||
});
|
||||
const headResponse = await client.send(headCommand);
|
||||
const sourceMetadata = headResponse.Metadata || {};
|
||||
|
||||
// 合并元数据
|
||||
const mergedMeta = { ...sourceMetadata, ...newMetadata };
|
||||
const { standardHeaders, customMetadata } = extractStandardHeaders(mergedMeta);
|
||||
|
||||
// 执行复制操作(同一对象,用于更新元数据)
|
||||
const copyCommand = new CopyObjectCommand({
|
||||
Bucket: bucketName,
|
||||
CopySource: `${bucketName}/${objectName}`,
|
||||
Key: objectName,
|
||||
ContentType: standardHeaders.ContentType,
|
||||
CacheControl: standardHeaders.CacheControl,
|
||||
ContentDisposition: standardHeaders.ContentDisposition,
|
||||
ContentEncoding: standardHeaders.ContentEncoding,
|
||||
ContentLanguage: standardHeaders.ContentLanguage,
|
||||
Expires: standardHeaders.Expires,
|
||||
Metadata: customMetadata,
|
||||
MetadataDirective: 'REPLACE',
|
||||
});
|
||||
|
||||
const copyResult = await client.send(copyCommand);
|
||||
return copyResult;
|
||||
};
|
||||
425
src/s3/core.ts
Normal file
425
src/s3/core.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
S3Client,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
HeadObjectCommand,
|
||||
CopyObjectCommand,
|
||||
type GetObjectCommandOutput,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import type { Readable } from 'node:stream';
|
||||
import fs from 'node:fs';
|
||||
import { omit } from 'es-toolkit';
|
||||
import {
|
||||
OssBaseOperation,
|
||||
ItemBucketMetadata,
|
||||
UploadedObjectInfo,
|
||||
StatObjectResult,
|
||||
ListObjectResult,
|
||||
ListFileObject,
|
||||
} from './type.ts';
|
||||
import { hash } from '../util/hash.ts';
|
||||
import { getContentType } from '../util/get-content-type.ts';
|
||||
import { extractStandardHeaders } from '../util/extract-standard-headers.ts';
|
||||
|
||||
export type OssBaseOptions<T = { [key: string]: any }> = {
|
||||
/**
|
||||
* 已经初始化好的 S3Client
|
||||
*/
|
||||
client: S3Client;
|
||||
/**
|
||||
* 桶名
|
||||
*/
|
||||
bucketName: string;
|
||||
/**
|
||||
* 前缀
|
||||
*/
|
||||
prefix?: string;
|
||||
} & T;
|
||||
|
||||
export class OssBase implements OssBaseOperation {
|
||||
client: S3Client;
|
||||
bucketName: string;
|
||||
prefix = '';
|
||||
/**
|
||||
* 计算字符串或者对象的的md5值
|
||||
*/
|
||||
hash = hash;
|
||||
|
||||
constructor(opts: OssBaseOptions) {
|
||||
if (!opts.client) {
|
||||
throw new Error('client is required');
|
||||
}
|
||||
this.bucketName = opts.bucketName;
|
||||
this.client = opts.client;
|
||||
this.prefix = opts?.prefix ?? '';
|
||||
}
|
||||
|
||||
setPrefix(prefix: string) {
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对象
|
||||
* @param objectName 对象名
|
||||
*/
|
||||
async getObject(objectName: string): Promise<GetObjectCommandOutput> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: `${this.prefix}${objectName}`,
|
||||
});
|
||||
const response = await this.client.send(command);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对象内容为字符串
|
||||
* @param objectName 对象名
|
||||
*/
|
||||
async getObjectAsString(objectName: string): Promise<string> {
|
||||
const response = await this.getObject(objectName);
|
||||
if (response.Body) {
|
||||
return await response.Body.transformToString();
|
||||
}
|
||||
throw new Error('Object body is empty');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对象内容为 JSON
|
||||
* @param objectName 对象名
|
||||
*/
|
||||
async getJson(objectName: string): Promise<Record<string, any>> {
|
||||
const str = await this.getObjectAsString(objectName);
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (error) {
|
||||
throw new Error('Failed to parse JSON');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传对象
|
||||
* @param objectName 对象名
|
||||
* @param data 数据
|
||||
* @param metaData 元数据
|
||||
* @param opts 选项
|
||||
*/
|
||||
async putObject(
|
||||
objectName: string,
|
||||
data: Buffer | string | Object | Readable,
|
||||
metaData: ItemBucketMetadata = {},
|
||||
opts?: { check?: boolean; isStream?: boolean; size?: number; contentType?: string },
|
||||
): Promise<UploadedObjectInfo> {
|
||||
let putData: Buffer | string | Readable;
|
||||
let contentLength: number | undefined = opts?.size;
|
||||
const isStream = opts?.isStream;
|
||||
|
||||
if (!isStream) {
|
||||
if (typeof data === 'string') {
|
||||
putData = data;
|
||||
contentLength = Buffer.byteLength(data);
|
||||
} else if (Buffer.isBuffer(data)) {
|
||||
putData = data;
|
||||
contentLength = data.length;
|
||||
} else {
|
||||
putData = JSON.stringify(data);
|
||||
contentLength = Buffer.byteLength(putData);
|
||||
}
|
||||
} else {
|
||||
putData = data as Readable;
|
||||
if (!contentLength) {
|
||||
throw new Error('Stream upload requires size parameter');
|
||||
}
|
||||
}
|
||||
|
||||
// 检查现有对象并合并元数据
|
||||
if (opts?.check) {
|
||||
const obj = await this.statObject(objectName, true);
|
||||
if (obj) {
|
||||
const omitMeta = ['size', 'content-type', 'cache-control', 'app-source'];
|
||||
const objMeta = JSON.parse(JSON.stringify(omit(obj.metaData, omitMeta)));
|
||||
metaData = {
|
||||
...objMeta,
|
||||
...metaData,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { standardHeaders, customMetadata } = extractStandardHeaders(metaData);
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: `${this.prefix}${objectName}`,
|
||||
Body: putData,
|
||||
ContentLength: contentLength,
|
||||
ContentType: opts?.contentType || standardHeaders.ContentType || getContentType(objectName),
|
||||
CacheControl: standardHeaders.CacheControl,
|
||||
ContentDisposition: standardHeaders.ContentDisposition,
|
||||
ContentEncoding: standardHeaders.ContentEncoding,
|
||||
ContentLanguage: standardHeaders.ContentLanguage,
|
||||
Expires: standardHeaders.Expires,
|
||||
Metadata: customMetadata,
|
||||
});
|
||||
|
||||
const response = await this.client.send(command);
|
||||
return {
|
||||
etag: response.ETag?.replace(/"/g, '') || '',
|
||||
versionId: response.VersionId || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param objectName 对象名
|
||||
* @param filePath 文件路径
|
||||
* @param metaData 元数据
|
||||
*/
|
||||
async fPutObject(
|
||||
objectName: string,
|
||||
filePath: string,
|
||||
metaData?: ItemBucketMetadata,
|
||||
): Promise<UploadedObjectInfo> {
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
const { standardHeaders, customMetadata } = extractStandardHeaders(metaData || {});
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: `${this.prefix}${objectName}`,
|
||||
Body: fileStream,
|
||||
ContentLength: stat.size,
|
||||
ContentType: standardHeaders.ContentType || getContentType(filePath),
|
||||
CacheControl: standardHeaders.CacheControl,
|
||||
ContentDisposition: standardHeaders.ContentDisposition,
|
||||
ContentEncoding: standardHeaders.ContentEncoding,
|
||||
ContentLanguage: standardHeaders.ContentLanguage,
|
||||
Expires: standardHeaders.Expires,
|
||||
Metadata: customMetadata,
|
||||
});
|
||||
|
||||
const response = await this.client.send(command);
|
||||
return {
|
||||
etag: response.ETag?.replace(/"/g, '') || '',
|
||||
versionId: response.VersionId || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除对象
|
||||
* @param objectName 对象名
|
||||
*/
|
||||
async deleteObject(objectName: string): Promise<void> {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: `${this.prefix}${objectName}`,
|
||||
});
|
||||
await this.client.send(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出对象
|
||||
* @param objectName 前缀
|
||||
* @param opts 选项
|
||||
*/
|
||||
async listObjects<IS_FILE = false>(
|
||||
objectName: string,
|
||||
opts?: { recursive?: boolean; startAfter?: string; maxKeys?: number },
|
||||
): Promise<IS_FILE extends true ? ListFileObject[] : ListObjectResult[]> {
|
||||
const prefix = `${this.prefix}${objectName}`;
|
||||
const results: ListObjectResult[] = [];
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
do {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: this.bucketName,
|
||||
Prefix: prefix,
|
||||
Delimiter: opts?.recursive ? undefined : '/',
|
||||
StartAfter: opts?.startAfter,
|
||||
MaxKeys: opts?.maxKeys || 1000,
|
||||
ContinuationToken: continuationToken,
|
||||
});
|
||||
|
||||
const response = await this.client.send(command);
|
||||
|
||||
// 处理文件对象
|
||||
if (response.Contents) {
|
||||
for (const item of response.Contents) {
|
||||
results.push({
|
||||
name: item.Key || '',
|
||||
size: item.Size || 0,
|
||||
lastModified: item.LastModified || new Date(),
|
||||
etag: item.ETag?.replace(/"/g, '') || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 处理目录(CommonPrefixes)
|
||||
if (response.CommonPrefixes && !opts?.recursive) {
|
||||
for (const prefix of response.CommonPrefixes) {
|
||||
results.push({
|
||||
prefix: prefix.Prefix || '',
|
||||
size: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
return results as IS_FILE extends true ? ListFileObject[] : ListObjectResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对象信息
|
||||
* @param objectName 对象名
|
||||
* @param checkFile 是否检查文件存在(不存在返回null而非抛错)
|
||||
*/
|
||||
async statObject(objectName: string, checkFile = true): Promise<StatObjectResult | null> {
|
||||
try {
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: `${this.prefix}${objectName}`,
|
||||
});
|
||||
const response = await this.client.send(command);
|
||||
|
||||
return {
|
||||
size: response.ContentLength || 0,
|
||||
etag: response.ETag?.replace(/"/g, '') || '',
|
||||
lastModified: response.LastModified || new Date(),
|
||||
metaData: (response.Metadata as ItemBucketMetadata) || {},
|
||||
versionId: response.VersionId || null,
|
||||
};
|
||||
} catch (e: any) {
|
||||
if (checkFile && (e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404)) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整的对象名称
|
||||
* @param objectName 对象名
|
||||
*/
|
||||
getObjectName(objectName: string): string {
|
||||
return `${this.prefix}${objectName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件hash是否一致
|
||||
* @param objectName 对象名
|
||||
* @param hash hash值
|
||||
* @param meta 元数据
|
||||
*/
|
||||
async checkObjectHash(
|
||||
objectName: string,
|
||||
hash: string,
|
||||
meta?: ItemBucketMetadata,
|
||||
): Promise<{ success: boolean; metaData: ItemBucketMetadata | null; obj: StatObjectResult | null; equalMeta?: boolean }> {
|
||||
const obj = await this.statObject(objectName, true);
|
||||
if (!obj) {
|
||||
return { success: false, metaData: null, obj: null, equalMeta: false };
|
||||
}
|
||||
const omitMeta = ['content-type', 'cache-control', 'app-source'];
|
||||
const metaData = omit(obj.metaData, omitMeta);
|
||||
let equalMeta = false;
|
||||
if (meta) {
|
||||
equalMeta = JSON.stringify(metaData) === JSON.stringify(meta);
|
||||
}
|
||||
return { success: obj.etag === hash, metaData, obj, equalMeta };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据
|
||||
* @param pathname 路径名
|
||||
* @param meta 元数据
|
||||
*/
|
||||
getMetadata(pathname: string, meta: ItemBucketMetadata = { 'app-source': 'user-app' }): ItemBucketMetadata {
|
||||
const isHtml = pathname.endsWith('.html');
|
||||
if (isHtml) {
|
||||
meta = {
|
||||
...meta,
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
'cache-control': 'no-cache',
|
||||
};
|
||||
} else {
|
||||
meta = {
|
||||
...meta,
|
||||
'content-type': getContentType(pathname),
|
||||
'cache-control': 'max-age=31536000, immutable',
|
||||
};
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制对象
|
||||
* @param sourceObject 源对象
|
||||
* @param targetObject 目标对象
|
||||
*/
|
||||
async copyObject(sourceObject: string, targetObject: string): Promise<any> {
|
||||
const command = new CopyObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
CopySource: `${this.bucketName}/${this.prefix}${sourceObject}`,
|
||||
Key: `${this.prefix}${targetObject}`,
|
||||
});
|
||||
const response = await this.client.send(command);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换对象元数据
|
||||
* @param objectName 对象名
|
||||
* @param meta 新元数据
|
||||
*/
|
||||
async replaceObject(objectName: string, meta: ItemBucketMetadata): Promise<any> {
|
||||
const key = `${this.prefix}${objectName}`;
|
||||
// 获取当前对象的元数据
|
||||
const stat = await this.statObject(objectName, false);
|
||||
const sourceMetadata = stat?.metaData || {};
|
||||
|
||||
const mergedMeta = { ...sourceMetadata, ...meta };
|
||||
const { standardHeaders, customMetadata } = extractStandardHeaders(mergedMeta);
|
||||
|
||||
const command = new CopyObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
CopySource: `${this.bucketName}/${key}`,
|
||||
Key: key,
|
||||
ContentType: standardHeaders.ContentType,
|
||||
CacheControl: standardHeaders.CacheControl,
|
||||
ContentDisposition: standardHeaders.ContentDisposition,
|
||||
ContentEncoding: standardHeaders.ContentEncoding,
|
||||
ContentLanguage: standardHeaders.ContentLanguage,
|
||||
Expires: standardHeaders.Expires,
|
||||
Metadata: customMetadata,
|
||||
MetadataDirective: 'REPLACE',
|
||||
});
|
||||
const response = await this.client.send(command);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建实例
|
||||
*/
|
||||
static create<T extends OssBase, U>(this: new (opts: OssBaseOptions<U>) => T, opts: OssBaseOptions<U>): T {
|
||||
return new this(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已有实例创建
|
||||
*/
|
||||
static fromBase<T extends OssBase, U>(
|
||||
this: new (opts: OssBaseOptions<U>) => T,
|
||||
createOpts: { oss: OssBase; opts: Partial<OssBaseOptions<U>> },
|
||||
): T {
|
||||
const base = createOpts.oss;
|
||||
const opts = createOpts.opts as any;
|
||||
return new this({
|
||||
client: base.client,
|
||||
bucketName: base.bucketName,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
}
|
||||
157
src/s3/type.ts
Normal file
157
src/s3/type.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { CopyObjectCommandOutput } from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
export type ItemBucketMetadata = Record<string, string>;
|
||||
|
||||
export type UploadedObjectInfo = {
|
||||
etag: string;
|
||||
lastModified?: Date;
|
||||
size?: number;
|
||||
versionId: string;
|
||||
metadata?: ItemBucketMetadata;
|
||||
};
|
||||
|
||||
export type StatObjectResult = {
|
||||
size: number;
|
||||
etag: string;
|
||||
lastModified: Date;
|
||||
metaData: ItemBucketMetadata;
|
||||
versionId?: string | null;
|
||||
};
|
||||
|
||||
export type ListFileObject = {
|
||||
name: string;
|
||||
size: number;
|
||||
lastModified: Date;
|
||||
etag: string;
|
||||
};
|
||||
|
||||
export type ListDirectoryObject = {
|
||||
prefix: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type ListObjectResult = ListFileObject | ListDirectoryObject;
|
||||
|
||||
export interface OssBaseOperation {
|
||||
prefix: string;
|
||||
|
||||
/**
|
||||
* 设置前缀
|
||||
* @param prefix 前缀
|
||||
*/
|
||||
setPrefix(prefix: string): void;
|
||||
|
||||
/**
|
||||
* 获取对象
|
||||
* @param objectName 对象名
|
||||
*/
|
||||
getObject(objectName: string): Promise<any>;
|
||||
|
||||
/**
|
||||
* 获取对象内容为字符串
|
||||
* @param objectName 对象名
|
||||
*/
|
||||
getObjectAsString?(objectName: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* 获取对象内容为 JSON
|
||||
* @param objectName 对象名
|
||||
*/
|
||||
getJson?(objectName: string): Promise<Record<string, any>>;
|
||||
|
||||
/**
|
||||
* 上传对象
|
||||
* @param objectName 对象名
|
||||
* @param data 数据
|
||||
* @param metaData 元数据
|
||||
* @param opts 选项
|
||||
*/
|
||||
putObject(
|
||||
objectName: string,
|
||||
data: Buffer | string | Object | Readable,
|
||||
metaData?: ItemBucketMetadata,
|
||||
opts?: { check?: boolean; isStream?: boolean; size?: number; contentType?: string },
|
||||
): Promise<UploadedObjectInfo>;
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param objectName 对象名
|
||||
* @param filePath 文件路径
|
||||
* @param metaData 元数据
|
||||
*/
|
||||
fPutObject(objectName: string, filePath: string, metaData?: ItemBucketMetadata): Promise<UploadedObjectInfo>;
|
||||
|
||||
/**
|
||||
* 获取对象信息
|
||||
* @param objectName 对象名
|
||||
* @param checkFile 是否检查文件存在(不存在返回null而非抛错)
|
||||
*/
|
||||
statObject(objectName: string, checkFile?: boolean): Promise<StatObjectResult | null>;
|
||||
|
||||
/**
|
||||
* 删除对象
|
||||
* @param objectName 对象名
|
||||
*/
|
||||
deleteObject(objectName: string): Promise<any>;
|
||||
|
||||
/**
|
||||
* 列出对象
|
||||
* @param objectName 前缀
|
||||
* @param opts 选项
|
||||
*/
|
||||
listObjects(
|
||||
objectName: string,
|
||||
opts?: {
|
||||
/** 是否递归 */
|
||||
recursive?: boolean;
|
||||
/** 开始位置 */
|
||||
startAfter?: string;
|
||||
/** 最大返回数量 */
|
||||
maxKeys?: number;
|
||||
},
|
||||
): Promise<ListObjectResult[]>;
|
||||
|
||||
/**
|
||||
* 获取完整的对象名称
|
||||
* @param objectName 对象名
|
||||
*/
|
||||
getObjectName?(objectName: string): string;
|
||||
|
||||
/**
|
||||
* 检查文件hash是否一致
|
||||
* @param objectName 对象名
|
||||
* @param hash hash值
|
||||
* @param meta 元数据
|
||||
*/
|
||||
checkObjectHash?(
|
||||
objectName: string,
|
||||
hash: string,
|
||||
meta?: ItemBucketMetadata,
|
||||
): Promise<{ success: boolean; metaData: ItemBucketMetadata | null; obj: StatObjectResult | null; equalMeta?: boolean }>;
|
||||
|
||||
/**
|
||||
* 获取元数据
|
||||
* @param pathname 路径名
|
||||
* @param meta 元数据
|
||||
*/
|
||||
getMetadata?(pathname: string, meta?: ItemBucketMetadata): ItemBucketMetadata;
|
||||
|
||||
/**
|
||||
* 复制对象
|
||||
* @param sourceObject 源对象
|
||||
* @param targetObject 目标对象
|
||||
*/
|
||||
copyObject(sourceObject: string, targetObject: string): Promise<CopyObjectCommandOutput>;
|
||||
|
||||
/**
|
||||
* 替换对象元数据
|
||||
* @param objectName 对象名
|
||||
* @param meta 新元数据
|
||||
*/
|
||||
replaceObject?(objectName: string, meta: ItemBucketMetadata): Promise<any>;
|
||||
}
|
||||
|
||||
export interface OssService extends OssBaseOperation {
|
||||
owner: string;
|
||||
}
|
||||
103
src/test/common.ts
Normal file
103
src/test/common.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
import { S3Client, ListObjectsV2Command, GetBucketMetadataConfigurationCommand, HeadObjectCommand, CopyObjectCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
export const s3Client = new S3Client({
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID || '',
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '',
|
||||
},
|
||||
region: process.env.S3_REGION,
|
||||
endpoint: 'https://tos-s3-cn-shanghai.volces.com',
|
||||
});
|
||||
|
||||
export const bucketName = process.env.S3_BUCKET_NAME;
|
||||
|
||||
export async function listS3Objects() {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: process.env.S3_BUCKET_NAME,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await s3Client.send(command);
|
||||
console.log('S3 Objects:', result.Contents);
|
||||
return result.Contents;
|
||||
} catch (error) {
|
||||
console.error('Error listing S3 objects:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// listS3Objects();
|
||||
|
||||
export async function getMetaData(key = 'readme.md') {
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: process.env.S3_BUCKET_NAME,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await s3Client.send(command);
|
||||
// 解码 TOS 返回的 URL 编码的元数据值
|
||||
const decodedMetadata: Record<string, string> = {};
|
||||
if (result.Metadata) {
|
||||
for (const [key, value] of Object.entries(result.Metadata)) {
|
||||
decodedMetadata[key] = decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
console.log('Metadata for', result);
|
||||
|
||||
return decodedMetadata;
|
||||
} catch (error) {
|
||||
console.error('Error getting metadata for', key, ':', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// const metadata = await getMetaData();
|
||||
// console.log('metadata', metadata);
|
||||
|
||||
export async function setMetaData(key = 'readme.md', metadata: Record<string, string>) {
|
||||
// 注意:S3 不支持直接更新对象的元数据。必须通过复制对象到自身来实现元数据的更新。
|
||||
const copySource = `${process.env.S3_BUCKET_NAME}/${key}`;
|
||||
|
||||
// 分离标准 HTTP 头和自定义元数据
|
||||
// 标准头应作为顶层参数,自定义元数据才放在 Metadata 中
|
||||
const standardHeaders: Record<string, any> = {};
|
||||
const customMetadata: Record<string, string> = {};
|
||||
|
||||
const standardHeaderKeys = ['content-type', 'cache-control', 'content-disposition', 'content-encoding', 'content-language', 'expires'];
|
||||
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (standardHeaderKeys.includes(lowerKey)) {
|
||||
// 使用驼峰命名
|
||||
const camelKey = lowerKey.split('-').map((word, index) =>
|
||||
index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join('');
|
||||
standardHeaders[camelKey] = value;
|
||||
} else {
|
||||
customMetadata[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const command = new CopyObjectCommand({
|
||||
Bucket: process.env.S3_BUCKET_NAME,
|
||||
Key: key,
|
||||
CopySource: copySource,
|
||||
...standardHeaders, // 标准头作为顶层参数
|
||||
Metadata: customMetadata, // 只有自定义元数据放在这里
|
||||
MetadataDirective: 'REPLACE', // 指定替换元数据
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await s3Client.send(command);
|
||||
console.log('Metadata updated successfully for', key);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error setting metadata for', key, ':', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// setMetaData('readme.md', { 'type': 'app', 'Content-Type': 'text/html' });
|
||||
@@ -1,10 +1,10 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { config } from 'dotenv';
|
||||
import { ConfigOssService } from '../services/index.ts';
|
||||
import { Client } from 'minio';
|
||||
import path from 'path';
|
||||
import { downloadObject } from '../util/download.ts';
|
||||
const cwd = process.cwd();
|
||||
dotenv.config({ path: path.resolve(cwd, '..', '..', '.env.dev') });
|
||||
config({ path: path.resolve(cwd, '..', '..', '.env.dev') });
|
||||
|
||||
console.log(
|
||||
'config',
|
||||
|
||||
23
src/test/test-s3.ts
Normal file
23
src/test/test-s3.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { OssBase } from "@/s3/core.ts";
|
||||
|
||||
import { s3Client, bucketName } from './common.ts';
|
||||
|
||||
const oss = new OssBase({
|
||||
client: s3Client,
|
||||
bucketName: bucketName,
|
||||
});
|
||||
|
||||
// const list = await oss.listObjects('');
|
||||
|
||||
// console.log(list);
|
||||
|
||||
// const obj = await oss.getObjectAsString('readme.md');
|
||||
// console.log(obj);
|
||||
|
||||
let putJson = {
|
||||
name: 'test',
|
||||
age: 18,
|
||||
}
|
||||
const objPut = await oss.putObject('a.json', putJson)
|
||||
|
||||
console.log(objPut);
|
||||
44
src/util/extract-standard-headers.ts
Normal file
44
src/util/extract-standard-headers.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export const standardHeaderKeys = ['content-type', 'cache-control', 'content-disposition', 'content-encoding', 'content-language', 'expires'];
|
||||
|
||||
export type StandardHeaders = {
|
||||
ContentType?: string;
|
||||
CacheControl?: string;
|
||||
ContentDisposition?: string;
|
||||
ContentEncoding?: string;
|
||||
ContentLanguage?: string;
|
||||
Expires?: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从元数据中提取标准头部和自定义元数据
|
||||
* @param metaData 原始元数据
|
||||
* @returns 标准头部和自定义元数据
|
||||
*/
|
||||
export function extractStandardHeaders(metaData: Record<string, string>): {
|
||||
standardHeaders: StandardHeaders;
|
||||
customMetadata: Record<string, string>;
|
||||
} {
|
||||
const standardHeaders: StandardHeaders = {};
|
||||
const customMetadata: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(metaData)) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (lowerKey === 'content-type') {
|
||||
standardHeaders.ContentType = value;
|
||||
} else if (lowerKey === 'cache-control') {
|
||||
standardHeaders.CacheControl = value;
|
||||
} else if (lowerKey === 'content-disposition') {
|
||||
standardHeaders.ContentDisposition = value;
|
||||
} else if (lowerKey === 'content-encoding') {
|
||||
standardHeaders.ContentEncoding = value;
|
||||
} else if (lowerKey === 'content-language') {
|
||||
standardHeaders.ContentLanguage = value;
|
||||
} else if (lowerKey === 'expires') {
|
||||
standardHeaders.Expires = new Date(value);
|
||||
} else {
|
||||
customMetadata[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { standardHeaders, customMetadata };
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './hash.ts';
|
||||
|
||||
export * from './get-content-type.ts';
|
||||
|
||||
export * from './extract-standard-headers.ts';
|
||||
|
||||
Reference in New Issue
Block a user