From f82439296bcd55083d971254113e24c727a051b6 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Sun, 17 Sep 2023 20:52:07 +0800 Subject: [PATCH] impl Object list() --- .eslintrc | 5 +- .gitignore | 3 + lib/bucket.js | 1 - lib/common/client/initOptions.js | 71 ---- lib/common/object/signatureUrl.js | 2 +- lib/common/signUtils.js | 176 -------- lib/common/utils/checkBucketName.d.ts | 1 - lib/common/utils/createRequest.js | 2 +- lib/common/utils/isIP.js | 10 - lib/common/utils/lowercaseKeyHeader.d.ts | 1 - lib/common/utils/lowercaseKeyHeader.js | 13 - package.json | 55 ++- src/OSSBaseClient.ts | 355 ++++++++++++++++ src/OSSObject.ts | 396 ++++++++++++++++++ src/exception/OSSClientException.ts | 13 + src/exception/index.ts | 1 + src/index.ts | 2 + src/type/Request.ts | 31 ++ src/type/index.ts | 1 + .../util/checkBucketName.ts | 4 +- src/util/index.ts | 3 + src/util/isIP.ts | 5 + src/util/sign.ts | 158 +++++++ test/OSSObject.test.ts | 131 ++++++ test/config.js | 22 - test/config.ts | 11 + test/util/isIP.test.ts | 88 ++++ test/utils.test.js | 226 ---------- tsconfig.json | 6 +- 29 files changed, 1251 insertions(+), 542 deletions(-) delete mode 100644 lib/common/client/initOptions.js delete mode 100644 lib/common/signUtils.js delete mode 100644 lib/common/utils/checkBucketName.d.ts delete mode 100644 lib/common/utils/isIP.js delete mode 100644 lib/common/utils/lowercaseKeyHeader.d.ts delete mode 100644 lib/common/utils/lowercaseKeyHeader.js create mode 100644 src/OSSBaseClient.ts create mode 100644 src/OSSObject.ts create mode 100644 src/exception/OSSClientException.ts create mode 100644 src/exception/index.ts create mode 100644 src/index.ts create mode 100644 src/type/Request.ts create mode 100644 src/type/index.ts rename lib/common/utils/checkBucketName.js => src/util/checkBucketName.ts (51%) create mode 100644 src/util/index.ts create mode 100644 src/util/isIP.ts create mode 100644 src/util/sign.ts create mode 100644 test/OSSObject.test.ts delete mode 100644 test/config.js create mode 100644 test/config.ts create mode 100644 test/util/isIP.test.ts delete mode 100644 test/utils.test.js diff --git a/.eslintrc b/.eslintrc index c799fe532..9bcdb4688 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,3 +1,6 @@ { - "extends": "eslint-config-egg" + "extends": [ + "eslint-config-egg/typescript", + "eslint-config-egg/lib/rules/enforce-node-prefix" + ] } diff --git a/.gitignore b/.gitignore index 8dae580ce..7bfa6aa31 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ package-lock.json es .eslintcache + +.tshy*/ +dist/ diff --git a/lib/bucket.js b/lib/bucket.js index 05c2affb3..20841b5c1 100644 --- a/lib/bucket.js +++ b/lib/bucket.js @@ -4,7 +4,6 @@ const { formatTag } = require('../lib/common/utils/formatTag'); const proto = exports; - function toArray(obj) { if (!obj) return []; if (Array.isArray(obj)) return obj; diff --git a/lib/common/client/initOptions.js b/lib/common/client/initOptions.js deleted file mode 100644 index 6c2306c38..000000000 --- a/lib/common/client/initOptions.js +++ /dev/null @@ -1,71 +0,0 @@ -const ms = require('humanize-ms'); -const urlutil = require('url'); -const { checkBucketName: _checkBucketName } = require('../utils/checkBucketName'); -const { setRegion } = require('../utils/setRegion'); -const { checkConfigValid } = require('../utils/checkConfigValid'); - -function setEndpoint(endpoint, secure) { - checkConfigValid(endpoint, 'endpoint'); - let url = urlutil.parse(endpoint); - - if (!url.protocol) { - url = urlutil.parse(`http${secure ? 's' : ''}://${endpoint}`); - } - - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - throw new Error('Endpoint protocol must be http or https.'); - } - - return url; -} - -module.exports = function(options) { - if (!options || !options.accessKeyId || !options.accessKeySecret) { - throw new Error('require accessKeyId, accessKeySecret'); - } - if (options.stsToken && !options.refreshSTSToken && !options.refreshSTSTokenInterval) { - console.warn( - "It's recommended to set 'refreshSTSToken' and 'refreshSTSTokenInterval' to refresh" + - ' stsToken、accessKeyId、accessKeySecret automatically when sts token has expired' - ); - } - if (options.bucket) { - _checkBucketName(options.bucket); - } - const opts = Object.assign( - { - region: 'oss-cn-hangzhou', - internal: false, - secure: false, - timeout: 60000, - bucket: null, - endpoint: null, - cname: false, - isRequestPay: false, - sldEnable: false, - headerEncoding: 'utf-8', - refreshSTSToken: null, - refreshSTSTokenInterval: 60000 * 5, - retryMax: 0, - }, - options - ); - - opts.accessKeyId = opts.accessKeyId.trim(); - opts.accessKeySecret = opts.accessKeySecret.trim(); - - if (opts.timeout) { - opts.timeout = ms(opts.timeout); - } - - if (opts.endpoint) { - opts.endpoint = setEndpoint(opts.endpoint, opts.secure); - } else if (opts.region) { - opts.endpoint = setRegion(opts.region, opts.internal, opts.secure); - } else { - throw new Error('require options.endpoint or options.region'); - } - - opts.inited = true; - return opts; -}; diff --git a/lib/common/object/signatureUrl.js b/lib/common/object/signatureUrl.js index b61b7b253..c6857551c 100644 --- a/lib/common/object/signatureUrl.js +++ b/lib/common/object/signatureUrl.js @@ -7,7 +7,7 @@ const { isIP } = require('../utils/isIP'); const proto = exports; /** - * signatureUrl + * signatureUrl * @deprecated will be deprecated in 7.x * @param {String} name object name * @param {Object} options options diff --git a/lib/common/signUtils.js b/lib/common/signUtils.js deleted file mode 100644 index d5ec53ea5..000000000 --- a/lib/common/signUtils.js +++ /dev/null @@ -1,176 +0,0 @@ -const crypto = require('crypto'); -const { lowercaseKeyHeader } = require('./utils/lowercaseKeyHeader'); - -/** - * buildCanonicalizedResource - * @param {String} resourcePath resourcePath - * @param {Object} parameters parameters - * @return {String} resource string - */ -exports.buildCanonicalizedResource = function buildCanonicalizedResource(resourcePath, parameters) { - let canonicalizedResource = `${resourcePath}`; - let separatorString = '?'; - - if (typeof parameters === 'string' && parameters.trim() !== '') { - canonicalizedResource += separatorString + parameters; - } else if (Array.isArray(parameters)) { - parameters.sort(); - canonicalizedResource += separatorString + parameters.join('&'); - } else if (parameters) { - const compareFunc = (entry1, entry2) => { - if (entry1[0] > entry2[0]) { - return 1; - } else if (entry1[0] < entry2[0]) { - return -1; - } - return 0; - }; - const processFunc = key => { - canonicalizedResource += separatorString + key; - if (parameters[key] || parameters[key] === 0) { - canonicalizedResource += `=${parameters[key]}`; - } - separatorString = '&'; - }; - Object.keys(parameters).sort(compareFunc).forEach(processFunc); - } - - return canonicalizedResource; -}; - -/** - * @param {String} method method - * @param {String} resourcePath resourcePath - * @param {Object} request request - * @param {String} expires expires - * @return {String} canonicalString - */ -exports.buildCanonicalString = function canonicalString(method, resourcePath, request, expires) { - request = request || {}; - const headers = lowercaseKeyHeader(request.headers); - const OSS_PREFIX = 'x-oss-'; - const ossHeaders = []; - const headersToSign = {}; - - let signContent = [ - method.toUpperCase(), - headers['content-md5'] || '', - headers['content-type'], - expires || headers['x-oss-date'], - ]; - - Object.keys(headers).forEach(key => { - const lowerKey = key.toLowerCase(); - if (lowerKey.indexOf(OSS_PREFIX) === 0) { - headersToSign[lowerKey] = String(headers[key]).trim(); - } - }); - - Object.keys(headersToSign).sort().forEach(key => { - ossHeaders.push(`${key}:${headersToSign[key]}`); - }); - - signContent = signContent.concat(ossHeaders); - - signContent.push(this.buildCanonicalizedResource(resourcePath, request.parameters)); - - return signContent.join('\n'); -}; - -/** - * @param {String} accessKeySecret accessKeySecret - * @param {String} canonicalString canonicalString - * @param {String} headerEncoding headerEncoding - */ -exports.computeSignature = function computeSignature(accessKeySecret, canonicalString, headerEncoding = 'utf-8') { - const signature = crypto.createHmac('sha1', accessKeySecret); - return signature.update(Buffer.from(canonicalString, headerEncoding)).digest('base64'); -}; - -/** - * @param {String} accessKeyId accessKeyId - * @param {String} accessKeySecret accessKeySecret - * @param {String} canonicalString canonicalString - * @param {String} headerEncoding headerEncoding - */ -exports.authorization = function authorization(accessKeyId, accessKeySecret, canonicalString, headerEncoding) { - return `OSS ${accessKeyId}:${this.computeSignature(accessKeySecret, canonicalString, headerEncoding)}`; -}; - -/** - * - * @param {String} accessKeySecret accessKeySecret - * @param {Object} options options - * @param {String} resource resource - * @param {Number} expires expires - * @param {String} headerEncoding headerEncoding - */ -exports._signatureForURL = function _signatureForURL(accessKeySecret, options = {}, resource, expires, headerEncoding) { - const headers = {}; - const { subResource = {} } = options; - - if (options.process) { - const processKeyword = 'x-oss-process'; - subResource[processKeyword] = options.process; - } - - if (options.trafficLimit) { - const trafficLimitKey = 'x-oss-traffic-limit'; - subResource[trafficLimitKey] = options.trafficLimit; - } - - if (options.response) { - Object.keys(options.response).forEach(k => { - const key = `response-${k.toLowerCase()}`; - subResource[key] = options.response[k]; - }); - } - - Object.keys(options).forEach(key => { - const lowerKey = key.toLowerCase(); - const value = options[key]; - if (lowerKey.indexOf('x-oss-') === 0) { - headers[lowerKey] = value; - } else if (lowerKey.indexOf('content-md5') === 0) { - headers[key] = value; - } else if (lowerKey.indexOf('content-type') === 0) { - headers[key] = value; - } - }); - - if (Object.prototype.hasOwnProperty.call(options, 'security-token')) { - subResource['security-token'] = options['security-token']; - } - - if (Object.prototype.hasOwnProperty.call(options, 'callback')) { - const json = { - callbackUrl: encodeURI(options.callback.url), - callbackBody: options.callback.body, - }; - if (options.callback.host) { - json.callbackHost = options.callback.host; - } - if (options.callback.contentType) { - json.callbackBodyType = options.callback.contentType; - } - subResource.callback = Buffer.from(JSON.stringify(json)).toString('base64'); - - if (options.callback.customValue) { - const callbackVar = {}; - Object.keys(options.callback.customValue).forEach(key => { - callbackVar[`x:${key}`] = options.callback.customValue[key]; - }); - subResource['callback-var'] = Buffer.from(JSON.stringify(callbackVar)).toString('base64'); - } - } - - const canonicalString = this.buildCanonicalString(options.method, resource, { - headers, - parameters: subResource, - }, expires.toString()); - - return { - Signature: this.computeSignature(accessKeySecret, canonicalString, headerEncoding), - subResource, - }; -}; diff --git a/lib/common/utils/checkBucketName.d.ts b/lib/common/utils/checkBucketName.d.ts deleted file mode 100644 index 4de489dc7..000000000 --- a/lib/common/utils/checkBucketName.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare const checkBucketName: (name: string, createBucket?: boolean) => void; diff --git a/lib/common/utils/createRequest.js b/lib/common/utils/createRequest.js index 0aac4c5ef..e16be1381 100644 --- a/lib/common/utils/createRequest.js +++ b/lib/common/utils/createRequest.js @@ -24,7 +24,7 @@ function createRequest(params) { date = +new Date() + this.options.amendTimeSkewed; } const headers = { - 'x-oss-date': dateFormat(date, "UTC:ddd, dd mmm yyyy HH:MM:ss 'GMT'"), + 'x-oss-date': dateFormat(new Date(), "UTC:ddd, dd mmm yyyy HH:MM:ss 'GMT'"), }; headers['User-Agent'] = this.userAgent; if (this.options.isRequestPay) { diff --git a/lib/common/utils/isIP.js b/lib/common/utils/isIP.js deleted file mode 100644 index ecd8af777..000000000 --- a/lib/common/utils/isIP.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Judge isIP include ipv4 or ipv6 - * @param {String} host host - * @return {Array} the multipart uploads - */ -exports.isIP = host => { - const ipv4Regex = /^(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}$/; - const ipv6Regex = /^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$/; - return ipv4Regex.test(host) || ipv6Regex.test(host); -}; diff --git a/lib/common/utils/lowercaseKeyHeader.d.ts b/lib/common/utils/lowercaseKeyHeader.d.ts deleted file mode 100644 index bcb2902a1..000000000 --- a/lib/common/utils/lowercaseKeyHeader.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function lowercaseKeyHeader(headers: any): {}; diff --git a/lib/common/utils/lowercaseKeyHeader.js b/lib/common/utils/lowercaseKeyHeader.js deleted file mode 100644 index cb198085e..000000000 --- a/lib/common/utils/lowercaseKeyHeader.js +++ /dev/null @@ -1,13 +0,0 @@ -const { isObject } = require('./isObject'); - -function lowercaseKeyHeader(headers) { - const lowercaseHeader = {}; - if (isObject(headers)) { - Object.keys(headers).forEach(key => { - lowercaseHeader[key.toLowerCase()] = headers[key]; - }); - } - return lowercaseHeader; -} - -exports.lowercaseKeyHeader = lowercaseKeyHeader; diff --git a/package.json b/package.json index 7b1c11b39..23531c0b0 100644 --- a/package.json +++ b/package.json @@ -2,20 +2,40 @@ "name": "oss-client", "version": "1.2.5", "description": "Aliyun OSS(Object Storage Service) Node.js Client", - "main": "lib/index.js", - "types": "index.d.ts", + "typings": "./dist/esm/index.d.ts", "files": [ - "lib", - "index.d.ts" + "dist", + "src" ], + "type": "module", + "tshy": { + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + } + }, + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", + "default": "./dist/commonjs/index.js" + } + } + }, "scripts": { "lint": "eslint lib test", - "test": "egg-bin test --parallel --ts false", - "test-local": "egg-bin test --ts false", + "test": "egg-bin test", + "test-local": "egg-bin test", "tsd": "tsd", - "cov": "egg-bin cov --parallel --ts false", + "cov": "egg-bin cov --parallel", "ci": "npm run lint && npm run tsd && npm run cov", - "contributor": "git-contributor" + "contributor": "git-contributor", + "prepublishOnly": "tshy && tshy-after" }, "repository": { "type": "git", @@ -38,22 +58,25 @@ "dependencies": { "address": "^1.2.0", "copy-to": "^2.0.1", - "dateformat": "^2.0.0", - "humanize-ms": "^1.2.0", "is-type-of": "^2.0.0", "jstoxml": "^2.0.0", - "merge-descriptors": "^1.0.1", "mime": "^3.0.0", - "oss-interface": "^1.0.1", + "ms": "^2.1.3", + "oss-interface": "^1.2.0", "sdk-base": "^4.2.1", - "stream-wormhole": "^1.0.4", + "stream-wormhole": "^2.0.0", "urllib": "^3.18.1", "utility": "^1.18.0", - "xml2js": "^0.4.16" + "xml2js": "^0.6.2" }, "devDependencies": { "@eggjs/tsconfig": "^1.1.0", + "@types/dateformat": "^5.0.0", + "@types/mime": "^3.0.1", + "@types/mocha": "^10.0.1", + "@types/ms": "^0.7.31", "@types/node": "^20.3.1", + "@types/xml2js": "^0.4.12", "egg-bin": "^6.4.1", "eslint": "^8.25.0", "eslint-config-egg": "^12.1.0", @@ -61,6 +84,8 @@ "mm": "^3.2.0", "sinon": "^1.17.7", "tsd": "^0.28.1", - "typescript": "^5.1.3" + "tshy": "^1.0.0", + "tshy-after": "^1.0.0", + "typescript": "^5.2.2" } } diff --git a/src/OSSBaseClient.ts b/src/OSSBaseClient.ts new file mode 100644 index 000000000..965767080 --- /dev/null +++ b/src/OSSBaseClient.ts @@ -0,0 +1,355 @@ +import { debuglog } from 'node:util'; +import assert from 'node:assert'; +import { createHash } from 'node:crypto'; +import { extname } from 'node:path'; +import { sendToWormhole } from 'stream-wormhole'; +import { parseStringPromise } from 'xml2js'; +import utility from 'utility'; +import mime from 'mime'; +import { HttpClient, RequestOptions, HttpClientResponse } from 'urllib'; +import ms from 'ms'; +import pkg from '../package.json' assert { type: "json" }; +import { authorization, buildCanonicalString, computeSignature } from './util/sign.js'; +import { OSSRequestParams, RequestHeaders, RequestParameters } from './type/Request.js'; +import { OSSClientException } from './exception/index.js'; + +const debug = debuglog('oss-client:client'); + +export interface OSSBaseClientInitOptions { + /** access key you create */ + accessKeyId: string; + /** access secret you create */ + accessKeySecret: string; + /** + * oss region domain. It takes priority over region. + * e.g.: + * - oss-cn-shanghai.aliyuncs.com + * - oss-cn-shanghai-internal.aliyuncs.com + */ + endpoint: string; + /** the bucket data region location, please see Data Regions, default is oss-cn-hangzhou. */ + region?: string | undefined; + /** access OSS with aliyun internal network or not, default is false. If your servers are running on aliyun too, you can set true to save lot of money. */ + internal?: boolean | undefined; + /** instance level timeout for all operations, default is 60s */ + timeout?: number | string; + isRequestPay?: boolean; +} + +export type OSSBaseClientOptions = Required & { + timeout: number; +}; + +export abstract class OSSBaseClient { + readonly #httpClient = new HttpClient(); + readonly #userAgent: string; + protected readonly options: OSSBaseClientOptions; + + constructor(options: OSSBaseClientInitOptions) { + this.options = this.#initOptions(options); + this.#userAgent = this.#getUserAgent(); + } + + /** public methods */ + + /** + * get OSS signature + * @return the signature + */ + signature(stringToSign: string) { + debug('authorization stringToSign: %s', stringToSign); + return computeSignature(this.options.accessKeySecret, stringToSign); + } + + /** protected methods */ + + /** + * get author header + * + * "Authorization: OSS " + Access Key Id + ":" + Signature + * + * Signature = base64(hmac-sha1(Access Key Secret + "\n" + * + VERB + "\n" + * + CONTENT-MD5 + "\n" + * + CONTENT-TYPE + "\n" + * + DATE + "\n" + * + CanonicalizedOSSHeaders + * + CanonicalizedResource)) + */ + protected authorization(method: string, resource: string, headers: RequestHeaders, subres?: RequestParameters) { + const stringToSign = buildCanonicalString(method.toUpperCase(), resource, { + headers, + parameters: subres, + }); + debug('stringToSign: %o', stringToSign); + const auth = authorization(this.options.accessKeyId, this.options.accessKeySecret, stringToSign); + debug('authorization: %o', auth); + return auth; + } + + protected escape(name: string) { + return utility.encodeURIComponent(name).replaceAll('%2F', '/'); + } + + protected abstract getRequestEndpoint(): string; + + protected getRequestURL(params: Pick) { + let resourcePath = '/'; + if (params.object) { + // Preserve '/' in result url + resourcePath += this.escape(params.object).replaceAll('+', '%2B'); + } + const urlObject = new URL(this.getRequestEndpoint()); + urlObject.pathname = resourcePath; + if (params.query) { + const query = params.query as Record; + for (const key in query) { + const value = query[key]; + urlObject.searchParams.set(key, `${value}`); + } + } + if (params.subres) { + let subresAsQuery: Record = {}; + if (typeof params.subres === 'string') { + subresAsQuery[params.subres] = ''; + } else if (Array.isArray(params.subres)) { + params.subres.forEach(k => { + subresAsQuery[k] = ''; + }); + } else { + subresAsQuery = params.subres; + } + for (const key in subresAsQuery) { + urlObject.searchParams.set(key, `${subresAsQuery[key]}`); + } + } + return urlObject.toString(); + } + + getResource(params: { bucket?: string; object?: string; }) { + let resource = '/'; + if (params.bucket) resource += `${params.bucket}/`; + if (params.object) resource += params.object; + return resource; + } + + createHttpClientRequestParams(params: OSSRequestParams) { + const headers: Record = { + ...params.headers, + // https://help.aliyun.com/zh/oss/developer-reference/include-signatures-in-the-authorization-header + // 此次操作的时间,Date必须为GMT格式,且不能为空。该值取自请求头的Date字段或者x-oss-date字段。当这两个字段同时存在时,以x-oss-date为准。 + // e.g.: Sun, 22 Nov 2015 08:16:38 GMT + 'x-oss-date': new Date().toUTCString(), + 'user-agent': this.#userAgent, + }; + if (this.options.isRequestPay) { + headers['x-oss-request-payer'] = 'requester'; + } + if (!headers['content-type']) { + let contentType: string | null = null; + if (params.mime) { + if (params.mime.includes('/')) { + contentType = params.mime; + } else { + contentType = mime.getType(params.mime); + } + } else if (params.object) { + contentType = mime.getType(extname(params.object)); + } + if (contentType) { + headers['content-type'] = contentType; + } + } + if (params.content) { + if (!params.disabledMD5) { + if (!headers['content-md5']) { + headers['content-md5'] = createHash('md5').update(Buffer.from(params.content)).digest('base64'); + } + } + if (!headers['content-length']) { + headers['content-length'] = `${params.content.length}`; + } + } + const authResource = this.getResource(params); + headers.authorization = this.authorization(params.method, authResource, headers, params.subres); + const url = this.getRequestURL(params); + debug('request %s %s, with headers %j, !!stream: %s', params.method, url, headers, !!params.stream); + const timeout = params.timeout ?? this.options.timeout; + const options: RequestOptions = { + method: params.method, + content: params.content, + stream: params.stream, + headers, + timeout, + writeStream: params.writeStream, + timing: true, + }; + if (params.streaming) { + options.dataType = 'stream'; + } + return { url, options }; + } + + /** + * request oss server + */ + protected async request(params: OSSRequestParams) { + const { url, options } = this.createHttpClientRequestParams(params) + const result = await this.#httpClient.request(url, options); + debug('response %s %s, got %s, headers: %j', params.method, url, result.status, result.headers); + let err; + if (!params.successStatuses?.includes(result.status)) { + err = await this.#createClientException(result); + if (params.streaming && result.res) { + // consume the response stream + await sendToWormhole(result.res); + } + throw err; + } + + let data = result.data as T; + if (params.xmlResponse) { + data = await this.#parseXML(result.data); + } + return { + data, + res: result.res, + }; + } + + + /** private methods */ + + #initOptions(options: OSSBaseClientInitOptions) { + assert(options.accessKeyId && options.accessKeySecret, 'require accessKeyId and accessKeySecret'); + assert(options.endpoint, 'require endpoint'); + let timeout = 60000; + if (options.timeout) { + if (typeof options.timeout === 'string') { + timeout = ms(options.timeout); + } else { + timeout = options.timeout; + } + } + + const initOptions = { + accessKeyId: options.accessKeyId.trim(), + accessKeySecret: options.accessKeySecret.trim(), + endpoint: options.endpoint, + region: options.region ?? 'oss-cn-hangzhou', + internal: options.internal ?? false, + isRequestPay: options.isRequestPay ?? false, + timeout, + } satisfies OSSBaseClientOptions; + return initOptions; + } + + /** + * Get User-Agent for Node.js + * @example + * oss-client/1.0.0 Node.js/5.3.0 (darwin; arm64) + */ + #getUserAgent() { + const sdk = `${pkg.name}/${pkg.version}`; + const platform = `Node.js/${process.version.slice(1)} (${process.platform}; ${process.arch})`; + return `${sdk} ${platform}`; + } + + async #parseXML(content: string | Buffer) { + if (Buffer.isBuffer(content)) { + content = content.toString(); + } + return await parseStringPromise(content, { + explicitRoot: false, + explicitArray: false, + }) as T; + } + + async #createClientException(result: HttpClientResponse) { + let err: OSSClientException; + let requestId = result.headers['x-oss-request-id'] as string ?? ''; + let hostId = ''; + if (!result.data || !result.data.length) { + // HEAD not exists resource + if (result.status === 404) { + err = new OSSClientException('NoSuchKey', 'Object not exists', requestId, hostId); + } else if (result.status === 412) { + err = new OSSClientException('PreconditionFailed', 'Pre condition failed', requestId, hostId); + } else { + err = new OSSClientException('Unknown', `Unknow error, status=${result.status}, raw error=${result}`, + requestId, hostId); + } + } else { + const xml = result.data.toString(); + debug('request response error xml: %o', xml); + + let info; + try { + info = await this.#parseXML(xml); + } catch (e: any) { + err = new OSSClientException('PreconditionFailed', `${e.message} (raw xml=${JSON.stringify(xml)})`, requestId, hostId); + return err; + } + + let msg = info?.Message ?? `unknow request error, status=${result.status}, raw xml=${JSON.stringify(xml)}`; + if (info?.Condition) { + msg += ` (condition=${info.Condition})`; + } + if (info?.RequestId) { + requestId = info.RequestId; + } + if (info?.HostId) { + hostId = info.HostId; + } + err = new OSSClientException(info?.Code ?? 'Unknown', msg, requestId, hostId); + } + + debug('generate error %o', err); + return err; + } +} + +// /** +// * Object operations +// */ +// merge(proto, require('./common/object')); +// merge(proto, require('./object')); +// merge(proto, require('./common/image')); +// /** +// * Bucket operations +// */ +// merge(proto, require('./common/bucket')); +// merge(proto, require('./bucket')); +// // multipart upload +// merge(proto, require('./managed-upload')); +// /** +// * RTMP operations +// */ +// merge(proto, require('./rtmp')); + +// /** +// * common multipart-copy +// */ +// merge(proto, require('./common/multipart-copy')); +// /** +// * Common module parallel +// */ +// merge(proto, require('./common/parallel')); +// /** +// * Multipart operations +// */ +// merge(proto, require('./common/multipart')); +// /** +// * ImageClient class +// */ +// Client.ImageClient = require('./image')(Client); +// /** +// * Cluster Client class +// */ +// Client.ClusterClient = require('./cluster')(Client); + +// /** +// * STS Client class +// */ +// Client.STS = require('./sts'); + diff --git a/src/OSSObject.ts b/src/OSSObject.ts new file mode 100644 index 000000000..ee0f3cc84 --- /dev/null +++ b/src/OSSObject.ts @@ -0,0 +1,396 @@ +import type { + // IObjectSimple, + // GetObjectOptions, + ListObjectsQuery, + RequestOptions, + ListObjectResult, + // PutObjectOptions, + // PutObjectResult, + // NormalSuccessResponse, + // HeadObjectOptions, + // HeadObjectResult, + // GetObjectResult, + // GetStreamOptions, + // GetStreamResult, + // CopyObjectOptions, + // CopyAndPutMetaResult, + // StorageType, + // OwnerType, + // UserMeta, + // ObjectCallback, +} from 'oss-interface'; + +import { + OSSBaseClientInitOptions, + OSSBaseClient, +} from './OSSBaseClient.js'; +import { + OSSRequestParams, + // RequestHeaders, RequestMeta, + RequestMethod } from './type/index.js'; +import { checkBucketName } from './util/index.js'; + +export interface OSSObjectClientInitOptions extends OSSBaseClientInitOptions { + bucket: string; +} + +export class OSSObject extends OSSBaseClient { +// export class OSSObject extends OSSBaseClient implements IObjectSimple { + #bucket: string; + #bucketEndpoint: string; + + constructor(options: OSSObjectClientInitOptions) { + checkBucketName(options.bucket); + super(options); + this.#bucket = options.bucket; + const urlObject = new URL(this.options.endpoint); + urlObject.hostname = `${this.#bucket}.${urlObject.hostname}`; + this.#bucketEndpoint = urlObject.toString(); + } + +// /** +// * append an object from String(file path)/Buffer/ReadableStream +// * @param {String} name the object key +// * @param {Mixed} file String(file path)/Buffer/ReadableStream +// * @param {Object} options options +// * @return {Object} result +// */ +// proto.append = async function append(name, file, options) { +// options = options || {}; +// if (options.position === undefined) options.position = '0'; +// options.subres = { +// append: '', +// position: options.position, +// }; +// options.method = 'POST'; + +// const result = await this.put(name, file, options); +// result.nextAppendPosition = result.res.headers['x-oss-next-append-position']; +// return result; +// }; + +// /** +// * put an object from String(file path)/Buffer/ReadableStream +// * @param {String} name the object key +// * @param {Mixed} file String(file path)/Buffer/ReadableStream +// * @param {Object} options options +// * {Object} options.callback The callback parameter is composed of a JSON string encoded in Base64 +// * {String} options.callback.url the OSS sends a callback request to this URL +// * {String} options.callback.host The host header value for initiating callback requests +// * {String} options.callback.body The value of the request body when a callback is initiated +// * {String} options.callback.contentType The Content-Type of the callback requests initiatiated +// * {Object} options.callback.customValue Custom parameters are a map of key-values, e.g: +// * customValue = { +// * key1: 'value1', +// * key2: 'value2' +// * } +// * @return {Object} result +// */ +// proto.put = async function put(name, file, options) { +// let content; +// options = options || {}; +// name = this._objectName(name); + +// if (Buffer.isBuffer(file)) { +// content = file; +// } else if (typeof file === 'string') { +// const stats = fs.statSync(file); +// if (!stats.isFile()) { +// throw new Error(`${file} is not file`); +// } +// options.mime = options.mime || mime.getType(path.extname(file)); +// options.contentLength = await this._getFileSize(file); +// const getStream = () => fs.createReadStream(file); +// const putStreamStb = (objectName, makeStream, configOption) => { +// return this.putStream(objectName, makeStream(), configOption); +// }; +// return await retry(putStreamStb, this.options.retryMax, { +// errorHandler: err => { +// const _errHandle = _err => { +// const statusErr = [ -1, -2 ].includes(_err.status); +// const requestErrorRetryHandle = this.options.requestErrorRetryHandle || (() => true); +// return statusErr && requestErrorRetryHandle(_err); +// }; +// if (_errHandle(err)) return true; +// return false; +// }, +// })(name, getStream, options); +// } else if (isReadable(file)) { +// return await this.putStream(name, file, options); +// } else { +// throw new TypeError('Must provide String/Buffer/ReadableStream for put.'); +// } + +// options.headers = options.headers || {}; +// this._convertMetaToHeaders(options.meta, options.headers); + +// const method = options.method || 'PUT'; +// const params = this._objectRequestParams(method, name, options); + +// callback.encodeCallback(params, options); + +// params.mime = options.mime; +// params.content = content; +// params.successStatuses = [ 200 ]; + +// const result = await this.request(params); + +// const ret = { +// name, +// url: this._objectUrl(name), +// res: result.res, +// }; + +// if (params.headers && params.headers['x-oss-callback']) { +// ret.data = JSON.parse(result.data.toString()); +// } + +// return ret; +// }; + +// /** +// * put an object from ReadableStream. +// * @param {String} name the object key +// * @param {Readable} stream the ReadableStream +// * @param {Object} options options +// * @return {Object} result +// */ +// proto.putStream = async function putStream(name, stream, options) { +// options = options || {}; +// options.headers = options.headers || {}; +// name = this._objectName(name); +// this._convertMetaToHeaders(options.meta, options.headers); + +// const method = options.method || 'PUT'; +// const params = this._objectRequestParams(method, name, options); +// callback.encodeCallback(params, options); +// params.mime = options.mime; +// params.stream = stream; +// params.successStatuses = [ 200 ]; + +// const result = await this.request(params); +// const ret = { +// name, +// url: this._objectUrl(name), +// res: result.res, +// }; + +// if (params.headers && params.headers['x-oss-callback']) { +// ret.data = JSON.parse(result.data.toString()); +// } + +// return ret; +// }; + +// proto.getStream = async function getStream(name, options) { +// options = options || {}; + +// if (options.process) { +// options.subres = options.subres || {}; +// options.subres['x-oss-process'] = options.process; +// } + +// const params = this._objectRequestParams('GET', name, options); +// params.customResponse = true; +// params.successStatuses = [ 200, 206, 304 ]; + +// const result = await this.request(params); + +// return { +// stream: result.res, +// res: { +// status: result.status, +// headers: result.headers, +// }, +// }; +// }; + +// proto.putMeta = async function putMeta(name, meta, options) { +// return await this.copy(name, name, { +// meta: meta || {}, +// timeout: options && options.timeout, +// ctx: options && options.ctx, +// }); +// }; + + async list(query: ListObjectsQuery, options?: RequestOptions): Promise { + // prefix, marker, max-keys, delimiter + const params = this.#objectRequestParams('GET', '', options); + if (query) { + params.query = query; + } + params.xmlResponse = true; + params.successStatuses = [ 200 ]; + + const { data, res } = await this.request(params); + let objects = data.Contents || []; + const that = this; + if (objects) { + if (!Array.isArray(objects)) { + objects = [ objects ]; + } + objects = objects.map((obj: any) => ({ + name: obj.Key, + url: that.#objectUrl(obj.Key), + lastModified: obj.LastModified, + etag: obj.ETag, + type: obj.Type, + size: Number(obj.Size), + storageClass: obj.StorageClass, + owner: { + id: obj.Owner.ID, + displayName: obj.Owner.DisplayName, + }, + })); + } + let prefixes = data.CommonPrefixes || null; + if (prefixes) { + if (!Array.isArray(prefixes)) { + prefixes = [ prefixes ]; + } + prefixes = prefixes.map((item: any) => item.Prefix); + } + return { + res, + objects, + prefixes, + nextMarker: data.NextMarker || null, + isTruncated: data.IsTruncated === 'true', + }; + } + +// proto.listV2 = async function listV2(query = {}, options = {}) { +// const continuation_token = query['continuation-token'] || query.continuationToken; +// delete query['continuation-token']; +// delete query.continuationToken; +// if (continuation_token) { +// options.subres = Object.assign( +// { +// 'continuation-token': continuation_token, +// }, +// options.subres +// ); +// } +// const params = this._objectRequestParams('GET', '', options); +// params.query = Object.assign({ 'list-type': 2 }, query); +// delete params.query['continuation-token']; +// delete query.continuationToken; +// params.xmlResponse = true; +// params.successStatuses = [ 200 ]; + +// const result = await this.request(params); +// let objects = result.data.Contents || []; +// const that = this; +// if (objects) { +// if (!Array.isArray(objects)) { +// objects = [ objects ]; +// } +// objects = objects.map(obj => ({ +// name: obj.Key, +// url: that._objectUrl(obj.Key), +// lastModified: obj.LastModified, +// etag: obj.ETag, +// type: obj.Type, +// size: Number(obj.Size), +// storageClass: obj.StorageClass, +// owner: obj.Owner +// ? { +// id: obj.Owner.ID, +// displayName: obj.Owner.DisplayName, +// } +// : null, +// })); +// } +// let prefixes = result.data.CommonPrefixes || null; +// if (prefixes) { +// if (!Array.isArray(prefixes)) { +// prefixes = [ prefixes ]; +// } +// prefixes = prefixes.map(item => item.Prefix); +// } +// return { +// res: result.res, +// objects, +// prefixes, +// isTruncated: result.data.IsTruncated === 'true', +// keyCount: +result.data.KeyCount, +// continuationToken: result.data.ContinuationToken || null, +// nextContinuationToken: result.data.NextContinuationToken || null, +// }; +// }; + +// /** +// * Restore Object +// * @param {String} name the object key +// * @param {Object} options {type : Archive or ColdArchive} +// * @return {{res}} result +// */ +// proto.restore = async function restore(name, options = { type: 'Archive' }) { +// options = options || {}; +// options.subres = Object.assign({ restore: '' }, options.subres); +// if (options.versionId) { +// options.subres.versionId = options.versionId; +// } +// const params = this._objectRequestParams('POST', name, options); +// if (options.type === 'ColdArchive') { +// const paramsXMLObj = { +// RestoreRequest: { +// Days: options.Days ? options.Days : 2, +// JobParameters: { +// Tier: options.JobParameters ? options.JobParameters : 'Standard', +// }, +// }, +// }; +// params.content = obj2xml(paramsXMLObj, { +// headers: true, +// }); +// params.mime = 'xml'; +// } +// params.successStatuses = [ 202 ]; + +// const result = await this.request(params); + +// return { +// res: result.res, +// }; +// }; + + protected getRequestEndpoint(): string { + return this.#bucketEndpoint; + } + + #objectUrl(name: string) { + return this.getRequestURL({ object: name }); + } + + /** + * generator request params + */ + #objectRequestParams(method: RequestMethod, name: string, + options?: Pick) { + name = this.#objectName(name); + const params: OSSRequestParams = { + object: name, + bucket: this.#bucket, + method, + headers: options?.headers, + subres: options?.subres, + timeout: options?.timeout, + }; + return params; + } + + #objectName(name: string) { + return name.replace(/^\/+/, ''); + } + + // #convertMetaToHeaders(meta: RequestMeta, headers: RequestHeaders) { + // if (!meta) { + // return; + // } + + // for (const key in meta) { + // headers[`x-oss-meta-${key}`] = meta[key]; + // } + // } +} diff --git a/src/exception/OSSClientException.ts b/src/exception/OSSClientException.ts new file mode 100644 index 000000000..57af7b632 --- /dev/null +++ b/src/exception/OSSClientException.ts @@ -0,0 +1,13 @@ +const REQUEST_ID_KEY = 'request-id'; +const RESPONSE_CODE_KEY = 'response-code'; +const RESPONSE_HOST_KEY = 'response-host'; + +export class OSSClientException extends Error { + code: string; + + constructor(code: string, message: string, requestId?: string, hostId?: string) { + super(`[${REQUEST_ID_KEY}=${requestId}, ${RESPONSE_CODE_KEY}=${code}, ${RESPONSE_HOST_KEY}=${hostId}] ${message}`); + this.code = code; + this.name = 'OSSClientException'; + } +} diff --git a/src/exception/index.ts b/src/exception/index.ts new file mode 100644 index 000000000..d57dc68e8 --- /dev/null +++ b/src/exception/index.ts @@ -0,0 +1 @@ +export * from './OSSClientException.js'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..e2f95cd3d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from './type/index.js'; +export * from './OSSObject.js'; diff --git a/src/type/Request.ts b/src/type/Request.ts new file mode 100644 index 000000000..bdf87600a --- /dev/null +++ b/src/type/Request.ts @@ -0,0 +1,31 @@ +import type { Readable, Writable } from 'node:stream'; +import { ListObjectsQuery } from 'oss-interface'; + +export type RequestParameters = string | string[] | Record; +export type RequestQuery = Record | ListObjectsQuery; +export type RequestHeaders = Record; +export type RequestMeta = Record; +export type RequestMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +export interface Request { + headers: RequestHeaders; + parameters?: RequestParameters; +} + +export interface OSSRequestParams { + method: RequestMethod; + headers?: RequestHeaders; + bucket?: string; + object?: string; + query?: RequestQuery; + mime?: string; + content?: string; + disabledMD5?: boolean; + stream?: Readable; + writeStream?: Writable; + timeout?: number; + subres?: RequestParameters; + xmlResponse?: boolean; + streaming?: boolean; + successStatuses?: number[]; +} diff --git a/src/type/index.ts b/src/type/index.ts new file mode 100644 index 000000000..d6cdce083 --- /dev/null +++ b/src/type/index.ts @@ -0,0 +1 @@ +export * from './Request.js'; diff --git a/lib/common/utils/checkBucketName.js b/src/util/checkBucketName.ts similarity index 51% rename from lib/common/utils/checkBucketName.js rename to src/util/checkBucketName.ts index c6f787cfd..7d10aa529 100644 --- a/lib/common/utils/checkBucketName.js +++ b/src/util/checkBucketName.ts @@ -1,6 +1,6 @@ -exports.checkBucketName = (name, createBucket = false) => { +export function checkBucketName(name: string, createBucket = false) { const bucketRegex = createBucket ? /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/ : /^[a-z0-9_][a-z0-9-_]{1,61}[a-z0-9_]$/; if (!bucketRegex.test(name)) { - throw new Error('The bucket must be conform to the specifications'); + throw new TypeError('The bucket must be conform to the specifications'); } }; diff --git a/src/util/index.ts b/src/util/index.ts new file mode 100644 index 000000000..706da857c --- /dev/null +++ b/src/util/index.ts @@ -0,0 +1,3 @@ +export * from './checkBucketName.js'; +export * from './isIP.js'; +export * from './sign.js'; diff --git a/src/util/isIP.ts b/src/util/isIP.ts new file mode 100644 index 000000000..c0bc5e4cb --- /dev/null +++ b/src/util/isIP.ts @@ -0,0 +1,5 @@ +import { isIP as _isIP } from 'node:net'; + +export function isIP(address: string) { + return _isIP(address) > 0; +} diff --git a/src/util/sign.ts b/src/util/sign.ts new file mode 100644 index 000000000..ab33173f6 --- /dev/null +++ b/src/util/sign.ts @@ -0,0 +1,158 @@ +import { debuglog } from 'node:util'; +import crypto from 'node:crypto'; +import { Request, RequestHeaders, RequestParameters } from '../type/Request.js'; + +const debug = debuglog('oss-client:sign'); +const OSS_PREFIX = 'x-oss-'; + +/** + * build canonicalized resource + * @see https://help.aliyun.com/zh/oss/developer-reference/include-signatures-in-the-authorization-header#section-rvv-dx2-xdb + * @return resource string + */ +function buildCanonicalizedResource(resourcePath: string, parameters?: RequestParameters) { + let canonicalizedResource = `${resourcePath}`; + let separatorString = '?'; + + if (typeof parameters === 'string') { + if (parameters.trim()) { + canonicalizedResource += separatorString + parameters; + } + } else if (Array.isArray(parameters)) { + parameters.sort(); + canonicalizedResource += separatorString + parameters.join('&'); + } else if (parameters) { + const compareFunc = (entry1: string, entry2: string) => { + if (entry1[0] > entry2[0]) { + return 1; + } else if (entry1[0] < entry2[0]) { + return -1; + } + return 0; + }; + const processFunc = (key: string) => { + canonicalizedResource += separatorString + key; + if (parameters[key] || parameters[key] === 0) { + canonicalizedResource += `=${parameters[key]}`; + } + separatorString = '&'; + }; + Object.keys(parameters).sort(compareFunc).forEach(processFunc); + } + debug('canonicalizedResource: %o', canonicalizedResource); + return canonicalizedResource; +}; + +function lowercaseKeyHeader(headers: RequestHeaders) { + const lowercaseHeaders: RequestHeaders = {}; + if (headers) { + for (const name in headers) { + lowercaseHeaders[name.toLowerCase()] = headers[name]; + } + } + return lowercaseHeaders; +} + +export function buildCanonicalString(method: string, resourcePath: string, request: Request, expires?: string) { + const headers = lowercaseKeyHeader(request.headers); + const headersToSign: RequestHeaders = {}; + const signContent: string[] = [ + method.toUpperCase(), + headers['content-md5'] || '', + headers['content-type'], + expires || headers['x-oss-date'], + ]; + + Object.keys(headers).forEach(key => { + if (key.startsWith(OSS_PREFIX)) { + headersToSign[key] = String(headers[key]).trim(); + } + }); + + Object.keys(headersToSign).sort().forEach(key => { + signContent.push(`${key}:${headersToSign[key]}`); + }); + signContent.push(buildCanonicalizedResource(resourcePath, request.parameters)); + + return signContent.join('\n'); +}; + +export function computeSignature(accessKeySecret: string, canonicalString: string) { + const signature = crypto.createHmac('sha1', accessKeySecret); + return signature.update(Buffer.from(canonicalString)).digest('base64'); +} + +export function authorization(accessKeyId: string, accessKeySecret: string, canonicalString: string) { + // https://help.aliyun.com/zh/oss/developer-reference/include-signatures-in-the-authorization-header + return `OSS ${accessKeyId}:${computeSignature(accessKeySecret, canonicalString)}`; +} + +// export function signatureForURL(accessKeySecret: string, options = {}, resource: string, expires: string) { +// const headers = {}; +// const { subResource = {} } = options; + +// if (options.process) { +// const processKeyword = 'x-oss-process'; +// subResource[processKeyword] = options.process; +// } + +// if (options.trafficLimit) { +// const trafficLimitKey = 'x-oss-traffic-limit'; +// subResource[trafficLimitKey] = options.trafficLimit; +// } + +// if (options.response) { +// Object.keys(options.response).forEach(k => { +// const key = `response-${k.toLowerCase()}`; +// subResource[key] = options.response[k]; +// }); +// } + +// Object.keys(options).forEach(key => { +// const lowerKey = key.toLowerCase(); +// const value = options[key]; +// if (lowerKey.indexOf('x-oss-') === 0) { +// headers[lowerKey] = value; +// } else if (lowerKey.indexOf('content-md5') === 0) { +// headers[key] = value; +// } else if (lowerKey.indexOf('content-type') === 0) { +// headers[key] = value; +// } +// }); + +// if (Object.prototype.hasOwnProperty.call(options, 'security-token')) { +// subResource['security-token'] = options['security-token']; +// } + +// if (Object.prototype.hasOwnProperty.call(options, 'callback')) { +// const json = { +// callbackUrl: encodeURI(options.callback.url), +// callbackBody: options.callback.body, +// }; +// if (options.callback.host) { +// json.callbackHost = options.callback.host; +// } +// if (options.callback.contentType) { +// json.callbackBodyType = options.callback.contentType; +// } +// subResource.callback = Buffer.from(JSON.stringify(json)).toString('base64'); + +// if (options.callback.customValue) { +// const callbackVar = {}; +// Object.keys(options.callback.customValue).forEach(key => { +// callbackVar[`x:${key}`] = options.callback.customValue[key]; +// }); +// subResource['callback-var'] = Buffer.from(JSON.stringify(callbackVar)).toString('base64'); +// } +// } + +// const canonicalString = buildCanonicalString(options.method, resource, { +// headers, +// parameters: subResource, +// }, expires.toString()); + +// return { +// Signature: this.computeSignature(accessKeySecret, canonicalString, headerEncoding), +// subResource, +// }; +// }; diff --git a/test/OSSObject.test.ts b/test/OSSObject.test.ts new file mode 100644 index 000000000..77a9f33a5 --- /dev/null +++ b/test/OSSObject.test.ts @@ -0,0 +1,131 @@ +import { strict as assert } from 'node:assert'; +// import os from 'node:os'; +import config from './config.js'; +import { OSSObject } from '../src/index.js'; +import { ObjectMeta } from 'oss-interface'; + +describe('test/OSSObject.test.ts', () => { + // const tmpdir = os.tmpdir(); + // const prefix = config.prefix; + // const bucket = config.oss.bucket; + const oss = new OSSObject(config.oss); + + describe('list()', () => { + // oss.jpg + // fun/test.jpg + // fun/movie/001.avi + // fun/movie/007.avi + // let listPrefix = `${prefix}oss-client/list/`; + // before(async () => { + // await store.put(`${listPrefix}oss.jpg`, Buffer.from('oss.jpg')); + // await store.put(`${listPrefix}fun/test.jpg`, Buffer.from('fun/test.jpg')); + // await store.put(`${listPrefix}fun/movie/001.avi`, Buffer.from('fun/movie/001.avi')); + // await store.put(`${listPrefix}fun/movie/007.avi`, Buffer.from('fun/movie/007.avi')); + // await store.put(`${listPrefix}other/movie/007.avi`, Buffer.from('other/movie/007.avi')); + // await store.put(`${listPrefix}other/movie/008.avi`, Buffer.from('other/movie/008.avi')); + // }); + + function checkObjectProperties(obj: ObjectMeta) { + assert.equal(typeof obj.name, 'string'); + assert.equal(typeof obj.lastModified, 'string'); + assert.equal(typeof obj.etag, 'string'); + assert(obj.type === 'Normal' || obj.type === 'Multipart'); + assert.equal(typeof obj.size, 'number'); + assert.equal(obj.storageClass, 'Standard'); + assert.equal(typeof obj.owner, 'object'); + assert.equal(typeof obj.owner!.id, 'string'); + assert.equal(typeof obj.owner!.displayName, 'string'); + } + + it('should list only 1 object', async () => { + const result = await oss.list({ + 'max-keys': 1, + }); + assert(result.objects.length <= 1); + result.objects.map(checkObjectProperties); + assert.equal(typeof result.nextMarker, 'string'); + assert(result.isTruncated); + assert.equal(result.prefixes, null); + assert(result.res.headers.date); + const obj = result.objects[0]; + assert.match(obj.url, /^https:\/\//); + assert(obj.url.endsWith(`/${obj.name}`)); + assert(obj.owner!.id); + assert(obj.size > 0); + }); + + // it('should list top 3 objects', async () => { + // const result = await store.list({ + // 'max-keys': 3, + // }); + // assert(result.objects.length <= 3); + // result.objects.map(checkObjectProperties); + // assert.equal(typeof result.nextMarker, 'string'); + // assert(result.isTruncated); + // assert.equal(result.prefixes, null); + + // // next 2 + // const result2 = await store.list({ + // 'max-keys': 2, + // marker: result.nextMarker, + // }); + // assert(result2.objects.length <= 2); + // result.objects.map(checkObjectProperties); + // assert.equal(typeof result2.nextMarker, 'string'); + // assert(result2.isTruncated); + // assert.equal(result2.prefixes, null); + // }); + + // it('should list with prefix', async () => { + // let result = await store.list({ + // prefix: `${listPrefix}fun/movie/`, + // }); + // assert.equal(result.objects.length, 2); + // result.objects.map(checkObjectProperties); + // assert.equal(result.nextMarker, null); + // assert(!result.isTruncated); + // assert.equal(result.prefixes, null); + + // result = await store.list({ + // prefix: `${listPrefix}fun/movie`, + // }); + // assert.equal(result.objects.length, 2); + // result.objects.map(checkObjectProperties); + // assert.equal(result.nextMarker, null); + // assert(!result.isTruncated); + // assert.equal(result.prefixes, null); + // }); + + // it('should list current dir files only', async () => { + // let result = await store.list({ + // prefix: listPrefix, + // delimiter: '/', + // }); + // assert.equal(result.objects.length, 1); + // result.objects.map(checkObjectProperties); + // assert.equal(result.nextMarker, null); + // assert(!result.isTruncated); + // assert.deepEqual(result.prefixes, [ `${listPrefix}fun/`, `${listPrefix}other/` ]); + + // result = await store.list({ + // prefix: `${listPrefix}fun/`, + // delimiter: '/', + // }); + // assert.equal(result.objects.length, 1); + // result.objects.map(checkObjectProperties); + // assert.equal(result.nextMarker, null); + // assert(!result.isTruncated); + // assert.deepEqual(result.prefixes, [ `${listPrefix}fun/movie/` ]); + + // result = await store.list({ + // prefix: `${listPrefix}fun/movie/`, + // delimiter: '/', + // }); + // assert.equal(result.objects.length, 2); + // result.objects.map(checkObjectProperties); + // assert.equal(result.nextMarker, null); + // assert(!result.isTruncated); + // assert.equal(result.prefixes, null); + // }); + }); +}); diff --git a/test/config.js b/test/config.js deleted file mode 100644 index 481d8254e..000000000 --- a/test/config.js +++ /dev/null @@ -1,22 +0,0 @@ -const { env } = process; - -const config = module.exports; - -config.oss = { - accessKeyId: env.ALI_SDK_OSS_ID, - accessKeySecret: env.ALI_SDK_OSS_SECRET, - region: env.ALI_SDK_OSS_REGION, - endpoint: env.ALI_SDK_OSS_ENDPOINT, - bucket: env.ALI_SDK_OSS_BUCKET, -}; - -config.sts = { - accessKeyId: env.ALI_SDK_STS_ID, - accessKeySecret: env.ALI_SDK_STS_SECRET, - roleArn: env.ALI_SDK_STS_ROLE, - bucket: env.ALI_SDK_STS_BUCKET, - endpoint: env.ALI_SDK_STS_ENDPOINT, -}; - -config.metaSyncTime = env.CI ? '30s' : '1000ms'; -config.timeout = '120s'; diff --git a/test/config.ts b/test/config.ts new file mode 100644 index 000000000..7308ab1e0 --- /dev/null +++ b/test/config.ts @@ -0,0 +1,11 @@ +export default { + prefix: `${process.platform}-${process.version}-${new Date().getTime()}/`, + oss: { + accessKeyId: process.env.OSS_CLIENT_ID!, + accessKeySecret: process.env.OSS_CLIENT_SECRET!, + region: process.env.OSS_CLIENT_REGION, + endpoint: process.env.OSS_CLIENT_ENDPOINT!, + bucket: process.env.OSS_CLIENT_BUCKET!, + }, + timeout: '120s', +} diff --git a/test/util/isIP.test.ts b/test/util/isIP.test.ts new file mode 100644 index 000000000..006f9e753 --- /dev/null +++ b/test/util/isIP.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert'; +import { isIP } from '../../src/util/isIP.js'; + +describe('test/util/isIP.test.ts', () => { + it('ipv4 test', () => { + // first length is 3 + assert.equal(isIP('200.255.255.255'), true); + assert.equal(isIP('223.255.255.255'), true); + assert.equal(isIP('224.255.255.255'), true); + assert.equal(isIP('192.0.0.1'), true); + assert.equal(isIP('127.0.0.1'), true); + assert.equal(isIP('100.0.0.1'), true); + assert.equal(isIP('90.0.0.1'), true); + assert.equal(isIP('9.0.0.1'), true); + assert.equal(isIP('090.0.0.1'), false); + assert.equal(isIP('009.0.0.1'), false); + assert.equal(isIP('200.1.255.255'), true); + assert.equal(isIP('200.001.255.255'), false); + + // first length is 1 or 2 + assert.equal(isIP('09.255.255.255'), false); + assert.equal(isIP('9.255.255.255'), true); + assert.equal(isIP('90.255.255.255'), true); + assert.equal(isIP('00.255.255.255'), false); + assert.equal(isIP('-.0.0.1'), false); + assert.equal(isIP('0.0.0.1'), true); + assert.equal(isIP('1.0.0.1'), true); + + // test last 3 byte + assert.equal(isIP('200.0.255.255'), true); + assert.equal(isIP('200.01.255.255'), false); + assert.equal(isIP('200.1.255.255'), true); + assert.equal(isIP('200.10.255.255'), true); + assert.equal(isIP('200.256.255.255'), false); + assert.equal(isIP('200.1.255.255'), true); + assert.equal(isIP('200.001.255.255'), false); + + assert.equal(isIP('200.255.0.255'), true); + assert.equal(isIP('200.255.01.255'), false); + assert.equal(isIP('200.255.1.255'), true); + assert.equal(isIP('200.255.10.255'), true); + assert.equal(isIP('200.255.256.255'), false); + assert.equal(isIP('200.255.001.255'), false); + assert.equal(isIP('200.255.1.255'), true); + + assert.equal(isIP('200.255.255.0'), true); + assert.equal(isIP('200.255.255.01'), false); + assert.equal(isIP('200.255.255.1'), true); + assert.equal(isIP('200.255.255.10'), true); + assert.equal(isIP('200.255.255.256'), false); + assert.equal(isIP('200.255.255.001'), false); + assert.equal(isIP('200.255.255.1'), true); + + // excetion + assert.equal(isIP('200'), false); + assert.equal(isIP('200.1'), false); + assert.equal(isIP('200.1.1'), false); + assert.equal(isIP('200.1.1.1.1'), false); + }); + + it('ipv6 test', () => { + assert.equal(isIP('1:2:3:4:5:6:7::'), true); + assert.equal(isIP('1:2:3:4:5:6:7:8'), true); + assert.equal(isIP('1:2:3:4:5:6::'), true); + assert.equal(isIP('1:2:3:4:5:6::8'), true); + assert.equal(isIP('1:2:3:4:5::'), true); + assert.equal(isIP('1:2:3:4:5::8'), true); + assert.equal(isIP('1:2:3:4::'), true); + assert.equal(isIP('1:2:3:4::8'), true); + assert.equal(isIP('1:2:3::'), true); + assert.equal(isIP('1:2:3::8'), true); + assert.equal(isIP('1:2::'), true); + assert.equal(isIP('1:2::8'), true); + assert.equal(isIP('1::'), true); + assert.equal(isIP('1::8'), true); + assert.equal(isIP('::'), true); + assert.equal(isIP('::8'), true); + assert.equal(isIP('::7:8'), true); + assert.equal(isIP('::6:7:8'), true); + assert.equal(isIP('::5:6:7:8'), true); + assert.equal(isIP('::4:5:6:7:8'), true); + assert.equal(isIP('::3:4:5:6:7:8'), true); + assert.equal(isIP('::2:3:4:5:6:7:8'), true); + assert.equal(isIP('A:0f:0F:FFFF:5:6:7:8'), true); + assert.equal(isIP('A:0f:0F:FFFF1:5:6:7:8'), false); + assert.equal(isIP('G:0f:0F:FFFF:5:6:7:8'), false); + }); +}); diff --git a/test/utils.test.js b/test/utils.test.js deleted file mode 100644 index 3dc134427..000000000 --- a/test/utils.test.js +++ /dev/null @@ -1,226 +0,0 @@ -const { isIP: _isIP } = require('../lib/common/utils/isIP'); -const { includesConf } = require('./utils'); -const assert = require('assert'); - -describe('test/test.js', () => { - it('ipv4 test', () => { - // first length is 3 - assert.equal(_isIP('200.255.255.255'), true); - assert.equal(_isIP('223.255.255.255'), true); - assert.equal(_isIP('224.255.255.255'), true); - assert.equal(_isIP('192.0.0.1'), true); - assert.equal(_isIP('127.0.0.1'), true); - assert.equal(_isIP('100.0.0.1'), true); - assert.equal(_isIP('090.0.0.1'), true); - assert.equal(_isIP('009.0.0.1'), true); - assert.equal(_isIP('200.001.255.255'), true); - - // first length is 1 or 2 - assert.equal(_isIP('09.255.255.255'), true); - assert.equal(_isIP('90.255.255.255'), true); - assert.equal(_isIP('00.255.255.255'), true); - assert.equal(_isIP('-.0.0.1'), false); - assert.equal(_isIP('0.0.0.1'), true); - assert.equal(_isIP('1.0.0.1'), true); - - // test last 3 byte - assert.equal(_isIP('200.0.255.255'), true); - assert.equal(_isIP('200.01.255.255'), true); - assert.equal(_isIP('200.10.255.255'), true); - assert.equal(_isIP('200.256.255.255'), false); - assert.equal(_isIP('200.001.255.255'), true); - - assert.equal(_isIP('200.255.0.255'), true); - assert.equal(_isIP('200.255.01.255'), true); - assert.equal(_isIP('200.255.10.255'), true); - assert.equal(_isIP('200.255.256.255'), false); - assert.equal(_isIP('200.255.001.255'), true); - - assert.equal(_isIP('200.255.255.0'), true); - assert.equal(_isIP('200.255.255.01'), true); - assert.equal(_isIP('200.255.255.10'), true); - assert.equal(_isIP('200.255.255.256'), false); - assert.equal(_isIP('200.255.255.001'), true); - - // excetion - assert.equal(_isIP('200.255.255.001'), true); - assert.equal(_isIP('200'), false); - assert.equal(_isIP('200.1'), false); - assert.equal(_isIP('200.1.1'), false); - assert.equal(_isIP('200.1.1.1.1'), false); - }); - it('ipv6 test', () => { - assert.equal(_isIP('1:2:3:4:5:6:7::'), true); - assert.equal(_isIP('1:2:3:4:5:6:7:8'), true); - assert.equal(_isIP('1:2:3:4:5:6::'), true); - assert.equal(_isIP('1:2:3:4:5:6::8'), true); - assert.equal(_isIP('1:2:3:4:5::'), true); - assert.equal(_isIP('1:2:3:4:5::8'), true); - assert.equal(_isIP('1:2:3:4::'), true); - assert.equal(_isIP('1:2:3:4::8'), true); - assert.equal(_isIP('1:2:3::'), true); - assert.equal(_isIP('1:2:3::8'), true); - assert.equal(_isIP('1:2::'), true); - assert.equal(_isIP('1:2::8'), true); - assert.equal(_isIP('1::'), true); - assert.equal(_isIP('1::8'), true); - assert.equal(_isIP('::'), true); - assert.equal(_isIP('::8'), true); - assert.equal(_isIP('::7:8'), true); - assert.equal(_isIP('::6:7:8'), true); - assert.equal(_isIP('::5:6:7:8'), true); - assert.equal(_isIP('::4:5:6:7:8'), true); - assert.equal(_isIP('::3:4:5:6:7:8'), true); - assert.equal(_isIP('::2:3:4:5:6:7:8'), true); - assert.equal(_isIP('A:0f:0F:FFFF:5:6:7:8'), true); - assert.equal(_isIP('A:0f:0F:FFFF1:5:6:7:8'), false); - assert.equal(_isIP('G:0f:0F:FFFF:5:6:7:8'), false); - }); -}); - -describe('test/includesConf.js', () => { - it('shoud return true when conf-item is primitive value', () => { - const data = { - testNum: 1, - testStr: '2', - testUndefined: undefined, - testNull: null, - testExtral: 'extral', - }; - const conf = { - testNum: 1, - testStr: '2', - testUndefined: undefined, - testNull: null, - }; - assert(includesConf(data, conf)); - }); - it('shoud return false when conf-item is primitive value and conf not in data', () => { - const data = { - testNum: 1, - testStr: '2', - testUndefined: undefined, - testNull: null, - testExtral: 'extral', - }; - const conf = { - testNonExist: 1, - }; - const conf1 = { - testExtral: 'test', - }; - assert(!includesConf(data, conf)); - assert(!includesConf(data, conf1)); - }); - it('shoud return true when conf-item is simple Array', () => { - const data = { - testArray1: [ 'extral', '1', 0, undefined ], - testExtral: 'extral', - }; - const conf = { - testArray1: [ '1', 0, undefined ], - }; - assert(includesConf(data, conf)); - }); - it('shoud return false when conf-item is simple Array and conf not in data', () => { - const data = { - testArray1: [ 'extral', '1', 0, undefined ], - testExtral: 'extral', - }; - const conf = { - testArray1: [ '1', 0, undefined, 'noexist' ], - }; - assert(!includesConf(data, conf)); - }); - it('shoud return true when conf-item is simple Object', () => { - const data = { - testObject: { test: 1, test1: 2 }, - testExtral: 'extral', - }; - const conf = { - testObject: { test: 1 }, - }; - assert(includesConf(data, conf)); - }); - it('shoud return false when conf-item is simple Object and conf not in data', () => { - const data = { - testObject: { test: 1, test1: 2 }, - testExtral: 'extral', - }; - const conf = { - testObject: { test: 1, noExist: 'test' }, - }; - assert(!includesConf(data, conf)); - }); - it('shoud return true when conf-item is complex Array', () => { - const data = { - testArray: [{ test: 1, test1: 2 }, { test: 2 }], - testExtral: 'extral', - }; - const conf = { - testArray: [{ test: 2 }], - }; - assert(includesConf(data, conf)); - }); - it('shoud return false when conf-item is complex Array and conf not in data', () => { - const data = { - testArray: [{ test: 1, test1: 2 }, { test: 2 }], - testExtral: 'extral', - }; - const conf = { - testArray: [{ test: 0 }], - }; - assert(!includesConf(data, conf)); - }); - it('shoud return true when conf-item is complex Object', () => { - const data = { - testObject: { - test01: { - test11: { - a: 1, - }, - test12: 1123, - }, - test02: [{ test11: 1 }, '123', 0, undefined, '456' ], - }, - testExtral: 'extral', - }; - const conf = { - testObject: { - test01: { - test11: { - a: 1, - }, - }, - test02: [{ test11: 1 }, '123', 0, undefined ], - }, - }; - assert(includesConf(data, conf)); - }); - it('shoud return false when conf-item is complex Object and conf not in data', () => { - const data = { - testObject: { - test01: { - test11: { - a: 1, - }, - test12: 1123, - }, - test02: [{ test11: 1 }, '123', 0, undefined, '456' ], - }, - testExtral: 'extral', - }; - const conf = { - testObject: { - test01: { - test11: { - a: 1, - b: 'test cpx', - }, - }, - test02: [{ test11: 1 }, '123', 0, undefined ], - }, - }; - assert(!includesConf(data, conf)); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index e99e989dc..ff41b7342 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,10 @@ { "extends": "@eggjs/tsconfig", "compilerOptions": { - "target": "ESNext" + "strict": true, + "noImplicitAny": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext" } }