feat: 各个COS的SDK中添加presignObjectUrl

This commit is contained in:
Pan Qiancheng 2025-12-29 16:32:19 +08:00
parent c33d55c680
commit 7a25050213
24 changed files with 824 additions and 33 deletions

View File

@ -133,4 +133,16 @@ export declare class ALiYunInstance {
process?: string;
response?: Record<string, string>;
}): Promise<string>;
/**
* URL
*/
presignObjectUrl(method: 'GET' | 'PUT' | 'POST' | 'DELETE', bucket: string, zone: ALiYunZone, key: string, options?: {
expires?: number;
process?: string;
response?: Record<string, string>;
}): Promise<{
url: string;
headers?: Record<string, string | string[]>;
formdata?: Record<string, any>;
}>;
}

View File

@ -410,4 +410,14 @@ export class ALiYunInstance {
throw new OakExternalException('aliyun', error.code, error.message);
}
}
/**
* 获取预签名对象URL统一接口
*/
async presignObjectUrl(method, bucket, zone, key, options) {
const url = await this.getSignedUrl(bucket, zone, key, {
...options,
method,
});
return { url };
}
}

View File

@ -25,4 +25,16 @@ export declare class CTYunInstance {
private buildCanonicalRequest;
private buildCanonicalQueryString;
private calculatePayloadHash;
/**
* URL
* 访URL AWS S3 Signature V4
*/
presignObjectUrl(method: 'GET' | 'PUT' | 'POST' | 'DELETE', bucket: string, zone: CTYunZone, key: string, options?: {
expires?: number;
contentType?: string;
}): Promise<{
url: string;
headers?: Record<string, string | string[]>;
formdata?: Record<string, any>;
}>;
}

View File

@ -309,4 +309,72 @@ export class CTYunInstance {
hash.update(payload);
return hash.digest('hex');
}
/**
* 获取预签名对象URL统一接口
* 生成天翼云对象的预签名访问URL兼容 AWS S3 Signature V4
*/
async presignObjectUrl(method, bucket, zone, key, options) {
const expiresIn = options?.expires || 3600;
const service = 's3';
// 对 key 进行 URI 编码(保留 /
const encodedKey = key
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
const path = `/${encodedKey}`;
const host = `${bucket}.${CTYun_ENDPOINT_LIST[zone].ul}`;
const baseUrl = `https://${host}${path}`;
// 生成 ISO8601 格式的日期
const date = new Date()
.toISOString()
.replace(/\.\d{3}Z$/, 'Z')
.replace(/[:\-]|\.\d{3}/g, '');
const dateStamp = date.substring(0, 8);
const credentialScope = `${dateStamp}/${zone}/${service}/aws4_request`;
// 构建查询参数(不含签名)
const queryParameters = {
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${this.accessKey}/${credentialScope}`,
'X-Amz-Date': date,
'X-Amz-Expires': expiresIn.toString(),
'X-Amz-SignedHeaders': 'host',
};
// 构建 Canonical Request
// 对于预签名 URLpayload hash 使用 UNSIGNED-PAYLOAD
const { canonicalRequest } = this.buildCanonicalRequest(method, path, queryParameters, { host: host }, // 注意header 名称应为小写
'UNSIGNED-PAYLOAD');
// 计算 Canonical Request 的 hash
const canonicalRequestHash = crypto
.createHash('sha256')
.update(canonicalRequest)
.digest('hex');
// 构建 String to Sign
const stringToSign = [
'AWS4-HMAC-SHA256',
date,
credentialScope,
canonicalRequestHash,
].join('\n');
// 生成签名密钥并计算签名
const signingKey = this.getSignatureKey(dateStamp, zone, service);
const signature = crypto
.createHmac('sha256', signingKey)
.update(stringToSign)
.digest('hex');
// 将签名添加到查询参数
queryParameters['X-Amz-Signature'] = signature;
// 构建最终 URL
const queryString = this.buildCanonicalQueryString(queryParameters);
const url = `${baseUrl}?${queryString}`;
// PUT 方法返回需要的 headers
if (method === 'PUT' && options?.contentType) {
return {
url,
headers: {
'Content-Type': options.contentType,
},
};
}
return { url };
}
}

View File

@ -224,4 +224,19 @@ export declare class QiniuCloudInstance {
private base64ToUrlSafe;
private hmacSha1;
private urlSafeBase64Encode;
/**
* URL
* 访//URL
*
* 使使
* 使 formdata
*/
presignObjectUrl(method: 'GET' | 'PUT' | 'POST' | 'DELETE', bucket: string, zone: QiniuZone, key: string, options?: {
expires?: number;
domain?: string;
}): Promise<{
url: string;
headers?: Record<string, string | string[]>;
formdata?: Record<string, any>;
}>;
}

View File

@ -808,4 +808,57 @@ export class QiniuCloudInstance {
const encoded = Buffer.from(jsonFlags).toString('base64');
return this.base64ToUrlSafe(encoded);
}
/**
* 获取预签名对象URL统一接口
* 生成七牛云对象的访问/下载/上传URL
*
* 注意七牛云下载需要使用存储桶绑定的域名不能使用内网域名
* 上传使用表单方式返回 formdata 中包含上传凭证
*/
async presignObjectUrl(method, bucket, zone, key, options) {
const deadline = Math.floor(Date.now() / 1000) + (options?.expires || 3600);
if (method === 'GET') {
// 下载场景:需要提供存储桶绑定的域名
if (!options?.domain) {
throw new OakExternalException('qiniu', 'MISSING_DOMAIN', '七牛云下载需要提供存储桶绑定的域名domain 参数)', {}, 'oak-external-sdk', {});
}
const baseUrl = `https://${options.domain}/${encodeURIComponent(key)}`;
// 生成下载凭证
// SignStr = downloadUrl?e=<deadline>
const signStr = `${baseUrl}?e=${deadline}`;
const encodedSign = this.hmacSha1(signStr, this.secretKey);
const encodedSignSafe = this.base64ToUrlSafe(encodedSign);
const downloadToken = `${this.accessKey}:${encodedSignSafe}`;
const url = `${baseUrl}?e=${deadline}&token=${downloadToken}`;
return { url };
}
else if (method === 'PUT' || method === 'POST') {
// 上传场景:七牛使用表单上传方式
const scope = key ? `${bucket}:${key}` : bucket;
// 构造上传策略
const putPolicy = {
scope,
deadline,
};
// 生成上传凭证
const encodedPolicy = this.urlSafeBase64Encode(JSON.stringify(putPolicy));
const encodedSign = this.hmacSha1(encodedPolicy, this.secretKey);
const encodedSignSafe = this.base64ToUrlSafe(encodedSign);
const uploadToken = `${this.accessKey}:${encodedSignSafe}:${encodedPolicy}`;
const uploadHost = QINIU_ENDPOINT_LIST[zone].ul;
const url = `https://${uploadHost}`;
return {
url,
formdata: {
key,
token: uploadToken,
// file 字段需要调用方自行添加
},
};
}
else {
// DELETE 等操作需要使用管理凭证不支持预签名URL方式
throw new OakExternalException('qiniu', 'UNSUPPORTED_METHOD', `七牛云不支持 ${method} 方法的预签名URL请使用 removeKodoFile 等管理接口`, {}, 'oak-external-sdk', {});
}
}
}

11
es/service/s3/S3.d.ts vendored
View File

@ -91,4 +91,15 @@ export declare class S3Instance {
* URL
*/
getSignedUrl(bucket: string, key: string, operation?: 'get' | 'put', expiresIn?: number, endpoint?: string): Promise<string>;
/**
* URL
*/
presignObjectUrl(method: 'GET' | 'PUT' | 'POST' | 'DELETE', bucket: string, zone: S3Zone, key: string, options?: {
expires?: number;
endpoint?: string;
}): Promise<{
url: string;
headers?: Record<string, string | string[]>;
formdata?: Record<string, any>;
}>;
}

View File

@ -1,5 +1,6 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand, HeadObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, ListPartsCommand, GetObjectCommand, } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { OakExternalException } from 'oak-domain/lib/types';
export class S3Instance {
accessKey;
secretKey;
@ -61,7 +62,7 @@ export class S3Instance {
};
}
catch (err) {
throw new Error(`生成S3上传URL失败: ${err.message}`);
throw new OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -79,7 +80,7 @@ export class S3Instance {
await client.send(command);
}
catch (err) {
throw new Error(`删除S3文件失败: ${err.message}`);
throw new OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -101,7 +102,7 @@ export class S3Instance {
if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
return false;
}
throw err;
throw new OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -139,7 +140,7 @@ export class S3Instance {
};
}
catch (err) {
throw new Error(`创建分片上传失败: ${err.message}`);
throw new OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -183,7 +184,7 @@ export class S3Instance {
};
}
catch (err) {
throw new Error(`准备分片上传失败: ${err.message}`);
throw new OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -208,7 +209,7 @@ export class S3Instance {
};
}
catch (err) {
throw new Error(`上传分片失败: ${err.message}`);
throw new OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -239,7 +240,7 @@ export class S3Instance {
};
}
catch (err) {
throw new Error(`完成分片上传失败: ${err.message}`);
throw new OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -258,7 +259,7 @@ export class S3Instance {
await client.send(command);
}
catch (err) {
throw new Error(`中止分片上传失败: ${err.message}`);
throw new OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -288,7 +289,7 @@ export class S3Instance {
};
}
catch (err) {
throw new Error(`列出分片失败: ${err.message}`);
throw new OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -306,7 +307,38 @@ export class S3Instance {
return url;
}
catch (err) {
throw new Error(`生成预签名URL失败: ${err.message}`);
throw new OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
* 获取预签名对象URL统一接口
*/
async presignObjectUrl(method, bucket, zone, key, options) {
try {
const client = options?.endpoint
? this.createClient(options.endpoint, this.defaultRegion)
: this.client;
let req;
switch (method) {
case 'GET':
req = new GetObjectCommand({ Bucket: bucket, Key: key });
break;
case 'PUT':
req = new PutObjectCommand({ Bucket: bucket, Key: key });
break;
case 'DELETE':
req = new DeleteObjectCommand({ Bucket: bucket, Key: key });
break;
case 'POST':
throw new Error('S3 不支持 POST 方法的预签名 URL');
}
const url = await getSignedUrl(client, req, {
expiresIn: options?.expires || 3600,
});
return { url };
}
catch (error) {
throw new OakExternalException('s3', error.code, error.message, error, 'oak-external-sdk', {});
}
}
}

View File

@ -17,4 +17,15 @@ export declare class TencentYunInstance {
private getSignInfo;
removeFile(srcBucket: string, zone: TencentYunZone, srcKey: string): Promise<any>;
isExistObject(srcBucket: string, zone: TencentYunZone, srcKey: string): Promise<boolean>;
/**
* URL
* 访URL
*/
presignObjectUrl(method: 'GET' | 'PUT' | 'POST' | 'DELETE', bucket: string, zone: TencentYunZone, key: string, options?: {
expires?: number;
}): Promise<{
url: string;
headers?: Record<string, string | string[]>;
formdata?: Record<string, any>;
}>;
}

View File

@ -1,4 +1,5 @@
import crypto from 'crypto';
import { OakExternalException, } from 'oak-domain/lib/types/Exception';
function getEndpoint(RegionID) {
return `cos-${RegionID}.myqcloud.com`;
}
@ -141,4 +142,36 @@ export class TencentYunInstance {
throw error;
}
}
/**
* 获取预签名对象URL统一接口
* 生成腾讯云对象的预签名访问URL
*/
async presignObjectUrl(method, bucket, zone, key, options) {
const client = new this.COS({
SecretId: this.accessKey,
SecretKey: this.secretKey,
});
try {
return new Promise((resolve, reject) => {
client.getObjectUrl({
Bucket: bucket,
Region: zone,
Key: key,
Method: method,
Expires: options?.expires || 3600,
Sign: true,
}, (err, data) => {
if (err) {
reject(new OakExternalException('tencent', err.code, err.message, err, 'oak-external-sdk', {}));
}
else {
resolve({ url: data.Url });
}
});
});
}
catch (error) {
throw new OakExternalException('tencent', error.code, error.message, error, 'oak-external-sdk', {});
}
}
}

View File

@ -133,4 +133,16 @@ export declare class ALiYunInstance {
process?: string;
response?: Record<string, string>;
}): Promise<string>;
/**
* URL
*/
presignObjectUrl(method: 'GET' | 'PUT' | 'POST' | 'DELETE', bucket: string, zone: ALiYunZone, key: string, options?: {
expires?: number;
process?: string;
response?: Record<string, string>;
}): Promise<{
url: string;
headers?: Record<string, string | string[]>;
formdata?: Record<string, any>;
}>;
}

View File

@ -414,5 +414,15 @@ class ALiYunInstance {
throw new Exception_1.OakExternalException('aliyun', error.code, error.message);
}
}
/**
* 获取预签名对象URL统一接口
*/
async presignObjectUrl(method, bucket, zone, key, options) {
const url = await this.getSignedUrl(bucket, zone, key, {
...options,
method,
});
return { url };
}
}
exports.ALiYunInstance = ALiYunInstance;

View File

@ -25,4 +25,16 @@ export declare class CTYunInstance {
private buildCanonicalRequest;
private buildCanonicalQueryString;
private calculatePayloadHash;
/**
* URL
* 访URL AWS S3 Signature V4
*/
presignObjectUrl(method: 'GET' | 'PUT' | 'POST' | 'DELETE', bucket: string, zone: CTYunZone, key: string, options?: {
expires?: number;
contentType?: string;
}): Promise<{
url: string;
headers?: Record<string, string | string[]>;
formdata?: Record<string, any>;
}>;
}

View File

@ -313,5 +313,73 @@ class CTYunInstance {
hash.update(payload);
return hash.digest('hex');
}
/**
* 获取预签名对象URL统一接口
* 生成天翼云对象的预签名访问URL兼容 AWS S3 Signature V4
*/
async presignObjectUrl(method, bucket, zone, key, options) {
const expiresIn = options?.expires || 3600;
const service = 's3';
// 对 key 进行 URI 编码(保留 /
const encodedKey = key
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
const path = `/${encodedKey}`;
const host = `${bucket}.${CTYun_ENDPOINT_LIST[zone].ul}`;
const baseUrl = `https://${host}${path}`;
// 生成 ISO8601 格式的日期
const date = new Date()
.toISOString()
.replace(/\.\d{3}Z$/, 'Z')
.replace(/[:\-]|\.\d{3}/g, '');
const dateStamp = date.substring(0, 8);
const credentialScope = `${dateStamp}/${zone}/${service}/aws4_request`;
// 构建查询参数(不含签名)
const queryParameters = {
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${this.accessKey}/${credentialScope}`,
'X-Amz-Date': date,
'X-Amz-Expires': expiresIn.toString(),
'X-Amz-SignedHeaders': 'host',
};
// 构建 Canonical Request
// 对于预签名 URLpayload hash 使用 UNSIGNED-PAYLOAD
const { canonicalRequest } = this.buildCanonicalRequest(method, path, queryParameters, { host: host }, // 注意header 名称应为小写
'UNSIGNED-PAYLOAD');
// 计算 Canonical Request 的 hash
const canonicalRequestHash = crypto_1.default
.createHash('sha256')
.update(canonicalRequest)
.digest('hex');
// 构建 String to Sign
const stringToSign = [
'AWS4-HMAC-SHA256',
date,
credentialScope,
canonicalRequestHash,
].join('\n');
// 生成签名密钥并计算签名
const signingKey = this.getSignatureKey(dateStamp, zone, service);
const signature = crypto_1.default
.createHmac('sha256', signingKey)
.update(stringToSign)
.digest('hex');
// 将签名添加到查询参数
queryParameters['X-Amz-Signature'] = signature;
// 构建最终 URL
const queryString = this.buildCanonicalQueryString(queryParameters);
const url = `${baseUrl}?${queryString}`;
// PUT 方法返回需要的 headers
if (method === 'PUT' && options?.contentType) {
return {
url,
headers: {
'Content-Type': options.contentType,
},
};
}
return { url };
}
}
exports.CTYunInstance = CTYunInstance;

View File

@ -224,4 +224,19 @@ export declare class QiniuCloudInstance {
private base64ToUrlSafe;
private hmacSha1;
private urlSafeBase64Encode;
/**
* URL
* 访//URL
*
* 使使
* 使 formdata
*/
presignObjectUrl(method: 'GET' | 'PUT' | 'POST' | 'DELETE', bucket: string, zone: QiniuZone, key: string, options?: {
expires?: number;
domain?: string;
}): Promise<{
url: string;
headers?: Record<string, string | string[]>;
formdata?: Record<string, any>;
}>;
}

View File

@ -812,5 +812,58 @@ class QiniuCloudInstance {
const encoded = buffer_1.Buffer.from(jsonFlags).toString('base64');
return this.base64ToUrlSafe(encoded);
}
/**
* 获取预签名对象URL统一接口
* 生成七牛云对象的访问/下载/上传URL
*
* 注意七牛云下载需要使用存储桶绑定的域名不能使用内网域名
* 上传使用表单方式返回 formdata 中包含上传凭证
*/
async presignObjectUrl(method, bucket, zone, key, options) {
const deadline = Math.floor(Date.now() / 1000) + (options?.expires || 3600);
if (method === 'GET') {
// 下载场景:需要提供存储桶绑定的域名
if (!options?.domain) {
throw new Exception_1.OakExternalException('qiniu', 'MISSING_DOMAIN', '七牛云下载需要提供存储桶绑定的域名domain 参数)', {}, 'oak-external-sdk', {});
}
const baseUrl = `https://${options.domain}/${encodeURIComponent(key)}`;
// 生成下载凭证
// SignStr = downloadUrl?e=<deadline>
const signStr = `${baseUrl}?e=${deadline}`;
const encodedSign = this.hmacSha1(signStr, this.secretKey);
const encodedSignSafe = this.base64ToUrlSafe(encodedSign);
const downloadToken = `${this.accessKey}:${encodedSignSafe}`;
const url = `${baseUrl}?e=${deadline}&token=${downloadToken}`;
return { url };
}
else if (method === 'PUT' || method === 'POST') {
// 上传场景:七牛使用表单上传方式
const scope = key ? `${bucket}:${key}` : bucket;
// 构造上传策略
const putPolicy = {
scope,
deadline,
};
// 生成上传凭证
const encodedPolicy = this.urlSafeBase64Encode(JSON.stringify(putPolicy));
const encodedSign = this.hmacSha1(encodedPolicy, this.secretKey);
const encodedSignSafe = this.base64ToUrlSafe(encodedSign);
const uploadToken = `${this.accessKey}:${encodedSignSafe}:${encodedPolicy}`;
const uploadHost = QINIU_ENDPOINT_LIST[zone].ul;
const url = `https://${uploadHost}`;
return {
url,
formdata: {
key,
token: uploadToken,
// file 字段需要调用方自行添加
},
};
}
else {
// DELETE 等操作需要使用管理凭证不支持预签名URL方式
throw new Exception_1.OakExternalException('qiniu', 'UNSUPPORTED_METHOD', `七牛云不支持 ${method} 方法的预签名URL请使用 removeKodoFile 等管理接口`, {}, 'oak-external-sdk', {});
}
}
}
exports.QiniuCloudInstance = QiniuCloudInstance;

View File

@ -91,4 +91,15 @@ export declare class S3Instance {
* URL
*/
getSignedUrl(bucket: string, key: string, operation?: 'get' | 'put', expiresIn?: number, endpoint?: string): Promise<string>;
/**
* URL
*/
presignObjectUrl(method: 'GET' | 'PUT' | 'POST' | 'DELETE', bucket: string, zone: S3Zone, key: string, options?: {
expires?: number;
endpoint?: string;
}): Promise<{
url: string;
headers?: Record<string, string | string[]>;
formdata?: Record<string, any>;
}>;
}

View File

@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.S3Instance = void 0;
const client_s3_1 = require("@aws-sdk/client-s3");
const s3_request_presigner_1 = require("@aws-sdk/s3-request-presigner");
const types_1 = require("oak-domain/lib/types");
class S3Instance {
accessKey;
secretKey;
@ -64,7 +65,7 @@ class S3Instance {
};
}
catch (err) {
throw new Error(`生成S3上传URL失败: ${err.message}`);
throw new types_1.OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -82,7 +83,7 @@ class S3Instance {
await client.send(command);
}
catch (err) {
throw new Error(`删除S3文件失败: ${err.message}`);
throw new types_1.OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -104,7 +105,7 @@ class S3Instance {
if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
return false;
}
throw err;
throw new types_1.OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -142,7 +143,7 @@ class S3Instance {
};
}
catch (err) {
throw new Error(`创建分片上传失败: ${err.message}`);
throw new types_1.OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -186,7 +187,7 @@ class S3Instance {
};
}
catch (err) {
throw new Error(`准备分片上传失败: ${err.message}`);
throw new types_1.OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -211,7 +212,7 @@ class S3Instance {
};
}
catch (err) {
throw new Error(`上传分片失败: ${err.message}`);
throw new types_1.OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -242,7 +243,7 @@ class S3Instance {
};
}
catch (err) {
throw new Error(`完成分片上传失败: ${err.message}`);
throw new types_1.OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -261,7 +262,7 @@ class S3Instance {
await client.send(command);
}
catch (err) {
throw new Error(`中止分片上传失败: ${err.message}`);
throw new types_1.OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -291,7 +292,7 @@ class S3Instance {
};
}
catch (err) {
throw new Error(`列出分片失败: ${err.message}`);
throw new types_1.OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
@ -309,7 +310,38 @@ class S3Instance {
return url;
}
catch (err) {
throw new Error(`生成预签名URL失败: ${err.message}`);
throw new types_1.OakExternalException('s3', err.code, err.message, err, 'oak-external-sdk', {});
}
}
/**
* 获取预签名对象URL统一接口
*/
async presignObjectUrl(method, bucket, zone, key, options) {
try {
const client = options?.endpoint
? this.createClient(options.endpoint, this.defaultRegion)
: this.client;
let req;
switch (method) {
case 'GET':
req = new client_s3_1.GetObjectCommand({ Bucket: bucket, Key: key });
break;
case 'PUT':
req = new client_s3_1.PutObjectCommand({ Bucket: bucket, Key: key });
break;
case 'DELETE':
req = new client_s3_1.DeleteObjectCommand({ Bucket: bucket, Key: key });
break;
case 'POST':
throw new Error('S3 不支持 POST 方法的预签名 URL');
}
const url = await (0, s3_request_presigner_1.getSignedUrl)(client, req, {
expiresIn: options?.expires || 3600,
});
return { url };
}
catch (error) {
throw new types_1.OakExternalException('s3', error.code, error.message, error, 'oak-external-sdk', {});
}
}
}

View File

@ -17,4 +17,15 @@ export declare class TencentYunInstance {
private getSignInfo;
removeFile(srcBucket: string, zone: TencentYunZone, srcKey: string): Promise<any>;
isExistObject(srcBucket: string, zone: TencentYunZone, srcKey: string): Promise<boolean>;
/**
* URL
* 访URL
*/
presignObjectUrl(method: 'GET' | 'PUT' | 'POST' | 'DELETE', bucket: string, zone: TencentYunZone, key: string, options?: {
expires?: number;
}): Promise<{
url: string;
headers?: Record<string, string | string[]>;
formdata?: Record<string, any>;
}>;
}

View File

@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.TencentYunInstance = void 0;
const tslib_1 = require("tslib");
const crypto_1 = tslib_1.__importDefault(require("crypto"));
const Exception_1 = require("oak-domain/lib/types/Exception");
function getEndpoint(RegionID) {
return `cos-${RegionID}.myqcloud.com`;
}
@ -145,5 +146,37 @@ class TencentYunInstance {
throw error;
}
}
/**
* 获取预签名对象URL统一接口
* 生成腾讯云对象的预签名访问URL
*/
async presignObjectUrl(method, bucket, zone, key, options) {
const client = new this.COS({
SecretId: this.accessKey,
SecretKey: this.secretKey,
});
try {
return new Promise((resolve, reject) => {
client.getObjectUrl({
Bucket: bucket,
Region: zone,
Key: key,
Method: method,
Expires: options?.expires || 3600,
Sign: true,
}, (err, data) => {
if (err) {
reject(new Exception_1.OakExternalException('tencent', err.code, err.message, err, 'oak-external-sdk', {}));
}
else {
resolve({ url: data.Url });
}
});
});
}
catch (error) {
throw new Exception_1.OakExternalException('tencent', error.code, error.message, error, 'oak-external-sdk', {});
}
}
}
exports.TencentYunInstance = TencentYunInstance;

View File

@ -538,4 +538,30 @@ export class ALiYunInstance {
throw new OakExternalException('aliyun', error.code, error.message);
}
}
/**
* URL
*/
async presignObjectUrl(
method: 'GET' | 'PUT' | 'POST' | 'DELETE',
bucket: string,
zone: ALiYunZone,
key: string,
options?: {
expires?: number;
process?: string;
response?: Record<string, string>;
}
): Promise<{
url: string;
headers?: Record<string, string | string[]>;
formdata?: Record<string, any>;
}> {
const url = await this.getSignedUrl(bucket, zone, key, {
...options,
method,
});
return { url };
}
}

View File

@ -297,9 +297,9 @@ export class CTYunInstance {
const authorizationHeader = [
'AWS4-HMAC-SHA256 Credential=' +
this.accessKey +
'/' +
credentialScope,
this.accessKey +
'/' +
credentialScope,
'SignedHeaders=' + signedHeaders,
'Signature=' + signature,
].join(', ');
@ -379,4 +379,104 @@ export class CTYunInstance {
hash.update(payload);
return hash.digest('hex');
}
/**
* URL
* 访URL AWS S3 Signature V4
*/
async presignObjectUrl(
method: 'GET' | 'PUT' | 'POST' | 'DELETE',
bucket: string,
zone: CTYunZone,
key: string,
options?: {
expires?: number;
contentType?: string; // PUT 上传时可指定 Content-Type
}
): Promise<{
url: string;
headers?: Record<string, string | string[]>;
formdata?: Record<string, any>;
}> {
const expiresIn = options?.expires || 3600;
const service = 's3';
// 对 key 进行 URI 编码(保留 /
const encodedKey = key
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
const path = `/${encodedKey}`;
const host = `${bucket}.${CTYun_ENDPOINT_LIST[zone].ul}`;
const baseUrl = `https://${host}${path}`;
// 生成 ISO8601 格式的日期
const date = new Date()
.toISOString()
.replace(/\.\d{3}Z$/, 'Z')
.replace(/[:\-]|\.\d{3}/g, '');
const dateStamp = date.substring(0, 8);
const credentialScope = `${dateStamp}/${zone}/${service}/aws4_request`;
// 构建查询参数(不含签名)
const queryParameters: Record<string, string> = {
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${this.accessKey}/${credentialScope}`,
'X-Amz-Date': date,
'X-Amz-Expires': expiresIn.toString(),
'X-Amz-SignedHeaders': 'host',
};
// 构建 Canonical Request
// 对于预签名 URLpayload hash 使用 UNSIGNED-PAYLOAD
const { canonicalRequest } = this.buildCanonicalRequest(
method,
path,
queryParameters,
{ host: host }, // 注意header 名称应为小写
'UNSIGNED-PAYLOAD'
);
// 计算 Canonical Request 的 hash
const canonicalRequestHash = crypto
.createHash('sha256')
.update(canonicalRequest)
.digest('hex');
// 构建 String to Sign
const stringToSign = [
'AWS4-HMAC-SHA256',
date,
credentialScope,
canonicalRequestHash,
].join('\n');
// 生成签名密钥并计算签名
const signingKey = this.getSignatureKey(dateStamp, zone, service);
const signature = crypto
.createHmac('sha256', signingKey)
.update(stringToSign)
.digest('hex');
// 将签名添加到查询参数
queryParameters['X-Amz-Signature'] = signature;
// 构建最终 URL
const queryString = this.buildCanonicalQueryString(queryParameters);
const url = `${baseUrl}?${queryString}`;
// PUT 方法返回需要的 headers
if (method === 'PUT' && options?.contentType) {
return {
url,
headers: {
'Content-Type': options.contentType,
},
};
}
return { url };
}
}

View File

@ -699,7 +699,7 @@ export class QiniuCloudInstance {
status: response.status,
});
}
}
/**
@ -772,15 +772,15 @@ export class QiniuCloudInstance {
const uploadToken = this.generateKodoUploadToken(bucket);
// 将Buffer转换为Uint8Array然后获取buffer
const bodyData = firstChunkData instanceof Buffer
const bodyData = firstChunkData instanceof Buffer
? firstChunkData.buffer.slice(
firstChunkData.byteOffset,
firstChunkData.byteOffset + firstChunkData.byteLength
)
)
: (firstChunkData as Uint8Array).buffer.slice(
(firstChunkData as Uint8Array).byteOffset,
(firstChunkData as Uint8Array).byteOffset + (firstChunkData as Uint8Array).byteLength
);
);
let response: Response;
try {
@ -839,15 +839,15 @@ export class QiniuCloudInstance {
const uploadToken = this.generateKodoUploadToken(bucket);
// 将Buffer转换为Uint8Array然后获取buffer
const bodyData = chunkData instanceof Buffer
const bodyData = chunkData instanceof Buffer
? chunkData.buffer.slice(
chunkData.byteOffset,
chunkData.byteOffset + chunkData.byteLength
)
)
: (chunkData as Uint8Array).buffer.slice(
(chunkData as Uint8Array).byteOffset,
(chunkData as Uint8Array).byteOffset + (chunkData as Uint8Array).byteLength
);
);
let response: Response;
try {
@ -906,12 +906,12 @@ export class QiniuCloudInstance {
const uploadHost = QINIU_ENDPOINT_LIST[zone].ul;
const encodedKey = this.urlSafeBase64Encode(key);
let path = `/mkfile/${fileSize}/key/${encodedKey}`;
if (mimeType) {
const encodedMimeType = this.urlSafeBase64Encode(mimeType);
path += `/mimeType/${encodedMimeType}`;
}
if (fileName) {
const encodedFileName = this.urlSafeBase64Encode(fileName);
path += `/fname/${encodedFileName}`;
@ -1122,4 +1122,92 @@ export class QiniuCloudInstance {
const encoded = Buffer.from(jsonFlags).toString('base64');
return this.base64ToUrlSafe(encoded);
}
}
/**
* URL
* 访//URL
*
* 使使
* 使 formdata
*/
async presignObjectUrl(
method: 'GET' | 'PUT' | 'POST' | 'DELETE',
bucket: string,
zone: QiniuZone,
key: string,
options?: {
expires?: number;
domain?: string; // 下载域名GET 时必须提供)
}
): Promise<{
url: string;
headers?: Record<string, string | string[]>;
formdata?: Record<string, any>;
}> {
const deadline = Math.floor(Date.now() / 1000) + (options?.expires || 3600);
if (method === 'GET') {
// 下载场景:需要提供存储桶绑定的域名
if (!options?.domain) {
throw new OakExternalException(
'qiniu',
'MISSING_DOMAIN',
'七牛云下载需要提供存储桶绑定的域名domain 参数)',
{},
'oak-external-sdk',
{}
);
}
const baseUrl = `https://${options.domain}/${encodeURIComponent(key)}`;
// 生成下载凭证
// SignStr = downloadUrl?e=<deadline>
const signStr = `${baseUrl}?e=${deadline}`;
const encodedSign = this.hmacSha1(signStr, this.secretKey);
const encodedSignSafe = this.base64ToUrlSafe(encodedSign);
const downloadToken = `${this.accessKey}:${encodedSignSafe}`;
const url = `${baseUrl}?e=${deadline}&token=${downloadToken}`;
return { url };
} else if (method === 'PUT' || method === 'POST') {
// 上传场景:七牛使用表单上传方式
const scope = key ? `${bucket}:${key}` : bucket;
// 构造上传策略
const putPolicy = {
scope,
deadline,
};
// 生成上传凭证
const encodedPolicy = this.urlSafeBase64Encode(JSON.stringify(putPolicy));
const encodedSign = this.hmacSha1(encodedPolicy, this.secretKey);
const encodedSignSafe = this.base64ToUrlSafe(encodedSign);
const uploadToken = `${this.accessKey}:${encodedSignSafe}:${encodedPolicy}`;
const uploadHost = QINIU_ENDPOINT_LIST[zone].ul;
const url = `https://${uploadHost}`;
return {
url,
formdata: {
key,
token: uploadToken,
// file 字段需要调用方自行添加
},
};
} else {
// DELETE 等操作需要使用管理凭证不支持预签名URL方式
throw new OakExternalException(
'qiniu',
'UNSUPPORTED_METHOD',
`七牛云不支持 ${method} 方法的预签名URL请使用 removeKodoFile 等管理接口`,
{},
'oak-external-sdk',
{}
);
}
}
}

View File

@ -173,4 +173,67 @@ export class TencentYunInstance {
throw error;
}
}
/**
* URL
* 访URL
*/
async presignObjectUrl(
method: 'GET' | 'PUT' | 'POST' | 'DELETE',
bucket: string,
zone: TencentYunZone,
key: string,
options?: {
expires?: number;
}
): Promise<{
url: string;
headers?: Record<string, string | string[]>;
formdata?: Record<string, any>;
}> {
const client = new this.COS({
SecretId: this.accessKey,
SecretKey: this.secretKey,
});
try {
return new Promise((resolve, reject) => {
client.getObjectUrl(
{
Bucket: bucket,
Region: zone,
Key: key,
Method: method,
Expires: options?.expires || 3600,
Sign: true,
},
(err: any, data: { Url: string }) => {
if (err) {
reject(
new OakExternalException(
'tencent',
err.code,
err.message,
err,
'oak-external-sdk',
{}
)
);
} else {
resolve({ url: data.Url });
}
}
);
});
} catch (error: any) {
throw new OakExternalException(
'tencent',
error.code,
error.message,
error,
'oak-external-sdk',
{}
);
}
}
}