diff --git a/docs/API.md b/docs/API.md index 48787929..53233b1b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1089,32 +1089,25 @@ minioClient.fPutObject('mybucket', '40mbfile', file, metaData, function (err, ob -### copyObject(bucketName, objectName, sourceObject, conditions[, callback]) +### copyObject(targetBucketName, targetObjectName, sourceBucketNameAndObjectName [,conditions]) Copy a source object into a new object in the specified bucket. **Parameters** -| Param | Type | Description | -| ------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `bucketName` | _string_ | Name of the bucket. | -| `objectName` | _string_ | Name of the object. | -| `sourceObject` | _string_ | Path of the file to be copied. | -| `conditions` | _CopyConditions_ | Conditions to be satisfied before allowing object copy. | -| `callback(err, {etag, lastModified})` | _function_ | Non-null `err` indicates error, `etag` _string_ and lastModified _Date_ are the etag and the last modified date of the object newly copied. If no callback is passed, a `Promise` is returned. | +| Param | Type | Description | +| ------------------------------- | ---------------- | ------------------------------------------------------- | +| `targetBucketName` | _string_ | Name of the bucket. | +| `targetObjectName` | _string_ | Name of the object. | +| `sourceBucketNameAndObjectName` | _string_ | Path of the file to be copied. | +| `conditions` | _CopyConditions_ | Conditions to be satisfied before allowing object copy. | **Example** ```js const conds = new Minio.CopyConditions() conds.setMatchETag('bd891862ea3e22c93ed53a098218791d') -minioClient.copyObject('mybucket', 'newobject', '/mybucket/srcobject', conds, function (e, data) { - if (e) { - return console.log(e) - } - console.log('Successfully copied the object:') - console.log('etag = ' + data.etag + ', lastModified = ' + data.lastModified) -}) +await minioClient.copyObject('mybucket', 'newobject', '/mybucket/srcobject', conds) ``` diff --git a/examples/copy-object.js b/examples/copy-object.mjs similarity index 72% rename from examples/copy-object.js rename to examples/copy-object.mjs index 9e0107cc..dd75ae99 100644 --- a/examples/copy-object.js +++ b/examples/copy-object.mjs @@ -14,7 +14,7 @@ * limitations under the License. */ -// Note: YOUR-ACCESSKEYID, YOUR-SECRETACCESSKEY, my-bucketname, my-objectname, +// Note: YOUR-ACCESSKEYID, YOUR-SECRETACCESSKEY, my-target-bucketname, my-target-objectname, // my-src-bucketname and my-src-objectname are dummy values, please replace // them with original values. @@ -29,16 +29,4 @@ const s3Client = new Minio.Client({ const conds = new Minio.CopyConditions() conds.setMatchETag('bd891862ea3e22c93ed53a098218791d') -s3Client.copyObject( - 'my-bucketname', - 'my-objectname', - '/my-src-bucketname/my-src-objectname', - conds, - function (e, data) { - if (e) { - return console.log(e) - } - console.log('Successfully copied the object:') - console.log('etag = ' + data.etag + ', lastModified = ' + data.lastModified) - }, -) +await s3Client.copyObject('my-target-bucketname', 'my-target-objectname', '/my-src-bucketname/my-src-objectname', conds) diff --git a/src/internal/client.ts b/src/internal/client.ts index e81a0a53..1d6c70a8 100644 --- a/src/internal/client.ts +++ b/src/internal/client.ts @@ -1,5 +1,6 @@ import * as crypto from 'node:crypto' import * as fs from 'node:fs' +import type { IncomingHttpHeaders } from 'node:http' import * as http from 'node:http' import * as https from 'node:https' import * as path from 'node:path' @@ -15,13 +16,22 @@ import xml2js from 'xml2js' import { CredentialProvider } from '../CredentialProvider.ts' import * as errors from '../errors.ts' import type { SelectResults } from '../helpers.ts' -import { DEFAULT_REGION, LEGAL_HOLD_STATUS, RETENTION_MODES, RETENTION_VALIDITY_UNITS } from '../helpers.ts' +import { + CopyDestinationOptions, + CopySourceOptions, + DEFAULT_REGION, + LEGAL_HOLD_STATUS, + RETENTION_MODES, + RETENTION_VALIDITY_UNITS, +} from '../helpers.ts' import { signV4 } from '../signing.ts' import { fsp, streamPromise } from './async.ts' +import { CopyConditions } from './copy-conditions.ts' import { Extensions } from './extensions.ts' import { extractMetadata, getContentLength, + getSourceVersionId, getVersionId, hashBinary, insertContentType, @@ -59,6 +69,9 @@ import type { BucketItemStat, BucketStream, BucketVersioningConfiguration, + CopyObjectParams, + CopyObjectResult, + CopyObjectResultV2, EncryptionConfig, GetObjectLegalHoldOptions, GetObjectRetentionOpts, @@ -2465,4 +2478,126 @@ export class TypedClient { const query = `uploadId=${removeUploadId}` await this.makeRequestAsyncOmit({ method, bucketName, objectName, query }, '', [204]) } + + private async copyObjectV1( + targetBucketName: string, + targetObjectName: string, + sourceBucketNameAndObjectName: string, + conditions?: null | CopyConditions, + ) { + if (typeof conditions == 'function') { + conditions = null + } + + if (!isValidBucketName(targetBucketName)) { + throw new errors.InvalidBucketNameError('Invalid bucket name: ' + targetBucketName) + } + if (!isValidObjectName(targetObjectName)) { + throw new errors.InvalidObjectNameError(`Invalid object name: ${targetObjectName}`) + } + if (!isString(sourceBucketNameAndObjectName)) { + throw new TypeError('sourceBucketNameAndObjectName should be of type "string"') + } + if (sourceBucketNameAndObjectName === '') { + throw new errors.InvalidPrefixError(`Empty source prefix`) + } + + if (conditions != null && !(conditions instanceof CopyConditions)) { + throw new TypeError('conditions should be of type "CopyConditions"') + } + + const headers: RequestHeaders = {} + headers['x-amz-copy-source'] = uriResourceEscape(sourceBucketNameAndObjectName) + + if (conditions) { + if (conditions.modified !== '') { + headers['x-amz-copy-source-if-modified-since'] = conditions.modified + } + if (conditions.unmodified !== '') { + headers['x-amz-copy-source-if-unmodified-since'] = conditions.unmodified + } + if (conditions.matchETag !== '') { + headers['x-amz-copy-source-if-match'] = conditions.matchETag + } + if (conditions.matchETagExcept !== '') { + headers['x-amz-copy-source-if-none-match'] = conditions.matchETagExcept + } + } + + const method = 'PUT' + + const res = await this.makeRequestAsync({ + method, + bucketName: targetBucketName, + objectName: targetObjectName, + headers, + }) + const body = await readAsString(res) + return xmlParsers.parseCopyObject(body) + } + + private async copyObjectV2( + sourceConfig: CopySourceOptions, + destConfig: CopyDestinationOptions, + ): Promise { + if (!(sourceConfig instanceof CopySourceOptions)) { + throw new errors.InvalidArgumentError('sourceConfig should of type CopySourceOptions ') + } + if (!(destConfig instanceof CopyDestinationOptions)) { + throw new errors.InvalidArgumentError('destConfig should of type CopyDestinationOptions ') + } + if (!destConfig.validate()) { + return Promise.reject() + } + if (!destConfig.validate()) { + return Promise.reject() + } + + const headers = Object.assign({}, sourceConfig.getHeaders(), destConfig.getHeaders()) + + const bucketName = destConfig.Bucket + const objectName = destConfig.Object + + const method = 'PUT' + + const res = await this.makeRequestAsync({ method, bucketName, objectName, headers }) + const body = await readAsString(res) + const copyRes = xmlParsers.parseCopyObject(body) + const resHeaders: IncomingHttpHeaders = res.headers + + const sizeHeaderValue = resHeaders && resHeaders['content-length'] + const size = typeof sizeHeaderValue === 'number' ? sizeHeaderValue : undefined + + return { + Bucket: destConfig.Bucket, + Key: destConfig.Object, + LastModified: copyRes.lastModified, + MetaData: extractMetadata(resHeaders as ResponseHeader), + VersionId: getVersionId(resHeaders as ResponseHeader), + SourceVersionId: getSourceVersionId(resHeaders as ResponseHeader), + Etag: sanitizeETag(resHeaders.etag), + Size: size, + } + } + + async copyObject(source: CopySourceOptions, dest: CopyDestinationOptions): Promise + async copyObject( + targetBucketName: string, + targetObjectName: string, + sourceBucketNameAndObjectName: string, + conditions?: CopyConditions, + ): Promise + async copyObject(...allArgs: CopyObjectParams): Promise { + if (typeof allArgs[0] === 'string') { + const [targetBucketName, targetObjectName, sourceBucketNameAndObjectName, conditions] = allArgs as [ + string, + string, + string, + CopyConditions?, + ] + return await this.copyObjectV1(targetBucketName, targetObjectName, sourceBucketNameAndObjectName, conditions) + } + const [source, dest] = allArgs as [CopySourceOptions, CopyDestinationOptions] + return await this.copyObjectV2(source, dest) + } } diff --git a/src/internal/type.ts b/src/internal/type.ts index 6bfbe005..582ac1dc 100644 --- a/src/internal/type.ts +++ b/src/internal/type.ts @@ -1,6 +1,9 @@ import type * as http from 'node:http' import type { Readable as ReadableStream } from 'node:stream' +import type { CopyDestinationOptions, CopySourceOptions } from '../helpers.ts' +import type { CopyConditions } from './copy-conditions.ts' + export type VersionIdentificator = { versionId?: string } @@ -416,3 +419,21 @@ export type RemoveObjectsResponse = VersionId?: string } } + +export type CopyObjectResultV1 = { + etag: string + lastModified: string | Date +} +export type CopyObjectResultV2 = { + Bucket?: string + Key?: string + LastModified: string | Date + MetaData?: ResponseHeader + VersionId?: string | null + SourceVersionId?: string | null + Etag?: string + Size?: number +} + +export type CopyObjectResult = CopyObjectResultV1 | CopyObjectResultV2 +export type CopyObjectParams = [CopySourceOptions, CopyDestinationOptions] | [string, string, string, CopyConditions?] diff --git a/src/internal/xml-parser.ts b/src/internal/xml-parser.ts index ed71f5a0..8b2fc107 100644 --- a/src/internal/xml-parser.ts +++ b/src/internal/xml-parser.ts @@ -8,7 +8,13 @@ import * as errors from '../errors.ts' import { SelectResults } from '../helpers.ts' import { isObject, parseXml, readableStream, sanitizeETag, sanitizeObjectKey, toArray } from './helper.ts' import { readAsString } from './response.ts' -import type { BucketItemFromList, BucketItemWithMetadata, ObjectLockInfo, ReplicationConfig } from './type.ts' +import type { + BucketItemFromList, + BucketItemWithMetadata, + CopyObjectResultV1, + ObjectLockInfo, + ReplicationConfig, +} from './type.ts' import { RETENTION_VALIDITY_UNITS } from './type.ts' // parse XML response for bucket region @@ -566,3 +572,30 @@ export function removeObjectsParser(xml: string) { } return [] } + +// parse XML response for copy object +export function parseCopyObject(xml: string): CopyObjectResultV1 { + const result: CopyObjectResultV1 = { + etag: '', + lastModified: '', + } + + let xmlobj = parseXml(xml) + if (!xmlobj.CopyObjectResult) { + throw new errors.InvalidXMLError('Missing tag: "CopyObjectResult"') + } + xmlobj = xmlobj.CopyObjectResult + if (xmlobj.ETag) { + result.etag = xmlobj.ETag.replace(/^"/g, '') + .replace(/"$/g, '') + .replace(/^"/g, '') + .replace(/"$/g, '') + .replace(/^"/g, '') + .replace(/"$/g, '') + } + if (xmlobj.LastModified) { + result.lastModified = new Date(xmlobj.LastModified) + } + + return result +} diff --git a/src/minio.d.ts b/src/minio.d.ts index 4599a391..9ec36cf3 100644 --- a/src/minio.d.ts +++ b/src/minio.d.ts @@ -147,20 +147,6 @@ export class Client extends TypedClient { listObjectsV2(bucketName: string, prefix?: string, recursive?: boolean, startAfter?: string): BucketStream - copyObject( - bucketName: string, - objectName: string, - sourceObject: string, - conditions: CopyConditions, - callback: ResultCallback, - ): void - copyObject( - bucketName: string, - objectName: string, - sourceObject: string, - conditions: CopyConditions, - ): Promise - removeIncompleteUpload(bucketName: string, objectName: string, callback: NoResultCallback): void removeIncompleteUpload(bucketName: string, objectName: string): Promise composeObject( diff --git a/src/minio.js b/src/minio.js index 53cb2473..f2f0816d 100644 --- a/src/minio.js +++ b/src/minio.js @@ -22,16 +22,13 @@ import * as querystring from 'query-string' import xml2js from 'xml2js' import * as errors from './errors.ts' -import { CopyDestinationOptions, CopySourceOptions } from './helpers.ts' +import { CopyDestinationOptions } from './helpers.ts' import { callbackify } from './internal/callbackify.js' import { TypedClient } from './internal/client.ts' import { CopyConditions } from './internal/copy-conditions.ts' import { calculateEvenSplits, - extractMetadata, getScope, - getSourceVersionId, - getVersionId, isBoolean, isFunction, isNumber, @@ -47,7 +44,6 @@ import { pipesetup, sanitizeETag, uriEscape, - uriResourceEscape, } from './internal/helper.ts' import { PostPolicy } from './internal/post-policy.ts' import { NotificationConfig, NotificationPoller } from './notification.ts' @@ -85,136 +81,6 @@ export class Client extends TypedClient { } this.userAgent = `${this.userAgent} ${appName}/${appVersion}` } - // Copy the object. - // - // __Arguments__ - // * `bucketName` _string_: name of the bucket - // * `objectName` _string_: name of the object - // * `srcObject` _string_: path of the source object to be copied - // * `conditions` _CopyConditions_: copy conditions that needs to be satisfied (optional, default `null`) - // * `callback(err, {etag, lastModified})` _function_: non null `err` indicates error, `etag` _string_ and `listModifed` _Date_ are respectively the etag and the last modified date of the newly copied object - copyObjectV1(arg1, arg2, arg3, arg4, arg5) { - var bucketName = arg1 - var objectName = arg2 - var srcObject = arg3 - var conditions, cb - if (typeof arg4 == 'function' && arg5 === undefined) { - conditions = null - cb = arg4 - } else { - conditions = arg4 - cb = arg5 - } - if (!isValidBucketName(bucketName)) { - throw new errors.InvalidBucketNameError('Invalid bucket name: ' + bucketName) - } - if (!isValidObjectName(objectName)) { - throw new errors.InvalidObjectNameError(`Invalid object name: ${objectName}`) - } - if (!isString(srcObject)) { - throw new TypeError('srcObject should be of type "string"') - } - if (srcObject === '') { - throw new errors.InvalidPrefixError(`Empty source prefix`) - } - - if (conditions !== null && !(conditions instanceof CopyConditions)) { - throw new TypeError('conditions should be of type "CopyConditions"') - } - - var headers = {} - headers['x-amz-copy-source'] = uriResourceEscape(srcObject) - - if (conditions !== null) { - if (conditions.modified !== '') { - headers['x-amz-copy-source-if-modified-since'] = conditions.modified - } - if (conditions.unmodified !== '') { - headers['x-amz-copy-source-if-unmodified-since'] = conditions.unmodified - } - if (conditions.matchETag !== '') { - headers['x-amz-copy-source-if-match'] = conditions.matchETag - } - if (conditions.matchEtagExcept !== '') { - headers['x-amz-copy-source-if-none-match'] = conditions.matchETagExcept - } - } - - var method = 'PUT' - this.makeRequest({ method, bucketName, objectName, headers }, '', [200], '', true, (e, response) => { - if (e) { - return cb(e) - } - var transformer = transformers.getCopyObjectTransformer() - pipesetup(response, transformer) - .on('error', (e) => cb(e)) - .on('data', (data) => cb(null, data)) - }) - } - - /** - * Internal Method to perform copy of an object. - * @param sourceConfig __object__ instance of CopySourceOptions @link ./helpers/CopySourceOptions - * @param destConfig __object__ instance of CopyDestinationOptions @link ./helpers/CopyDestinationOptions - * @param cb __function__ called with null if there is an error - * @returns Promise if no callack is passed. - */ - copyObjectV2(sourceConfig, destConfig, cb) { - if (!(sourceConfig instanceof CopySourceOptions)) { - throw new errors.InvalidArgumentError('sourceConfig should of type CopySourceOptions ') - } - if (!(destConfig instanceof CopyDestinationOptions)) { - throw new errors.InvalidArgumentError('destConfig should of type CopyDestinationOptions ') - } - if (!destConfig.validate()) { - return false - } - if (!destConfig.validate()) { - return false - } - if (!isFunction(cb)) { - throw new TypeError('callback should be of type "function"') - } - - const headers = Object.assign({}, sourceConfig.getHeaders(), destConfig.getHeaders()) - - const bucketName = destConfig.Bucket - const objectName = destConfig.Object - - const method = 'PUT' - this.makeRequest({ method, bucketName, objectName, headers }, '', [200], '', true, (e, response) => { - if (e) { - return cb(e) - } - const transformer = transformers.getCopyObjectTransformer() - pipesetup(response, transformer) - .on('error', (e) => cb(e)) - .on('data', (data) => { - const resHeaders = response.headers - - const copyObjResponse = { - Bucket: destConfig.Bucket, - Key: destConfig.Object, - LastModified: data.LastModified, - MetaData: extractMetadata(resHeaders), - VersionId: getVersionId(resHeaders), - SourceVersionId: getSourceVersionId(resHeaders), - Etag: sanitizeETag(resHeaders.etag), - Size: +resHeaders['content-length'], - } - - return cb(null, copyObjResponse) - }) - }) - } - - // Backward compatibility for Copy Object API. - copyObject(...allArgs) { - if (allArgs[0] instanceof CopySourceOptions && allArgs[1] instanceof CopyDestinationOptions) { - return this.copyObjectV2(...arguments) - } - return this.copyObjectV1(...arguments) - } // list a batch of objects listObjectsQuery(bucketName, prefix, marker, listQueryOpts = {}) { @@ -978,9 +844,6 @@ export class Client extends TypedClient { } } -// Promisify various public-facing APIs on the Client module. -Client.prototype.copyObject = promisify(Client.prototype.copyObject) - Client.prototype.presignedUrl = promisify(Client.prototype.presignedUrl) Client.prototype.presignedGetObject = promisify(Client.prototype.presignedGetObject) Client.prototype.presignedPutObject = promisify(Client.prototype.presignedPutObject) @@ -1032,3 +895,4 @@ Client.prototype.removeBucketEncryption = callbackify(Client.prototype.removeBuc Client.prototype.getObjectRetention = callbackify(Client.prototype.getObjectRetention) Client.prototype.removeObjects = callbackify(Client.prototype.removeObjects) Client.prototype.removeIncompleteUpload = callbackify(Client.prototype.removeIncompleteUpload) +Client.prototype.copyObject = callbackify(Client.prototype.copyObject) diff --git a/src/transformers.js b/src/transformers.js index 9225650a..aa883dd8 100644 --- a/src/transformers.js +++ b/src/transformers.js @@ -95,11 +95,6 @@ export function getHashSummer(enableSHA256) { // Following functions return a stream object that parses XML // and emits suitable Javascript objects. -// Parses CopyObject response. -export function getCopyObjectTransformer() { - return getConcater(xmlParsers.parseCopyObject) -} - // Parses listObjects response. export function getListObjectsTransformer() { return getConcater(xmlParsers.parseListObjects) diff --git a/src/xml-parsers.js b/src/xml-parsers.js index 44c1505d..522edc30 100644 --- a/src/xml-parsers.js +++ b/src/xml-parsers.js @@ -25,33 +25,6 @@ const fxpWithoutNumParser = new XMLParser({ }, }) -// parse XML response for copy object -export function parseCopyObject(xml) { - var result = { - etag: '', - lastModified: '', - } - - var xmlobj = parseXml(xml) - if (!xmlobj.CopyObjectResult) { - throw new errors.InvalidXMLError('Missing tag: "CopyObjectResult"') - } - xmlobj = xmlobj.CopyObjectResult - if (xmlobj.ETag) { - result.etag = xmlobj.ETag.replace(/^"/g, '') - .replace(/"$/g, '') - .replace(/^"/g, '') - .replace(/"$/g, '') - .replace(/^"/g, '') - .replace(/"$/g, '') - } - if (xmlobj.LastModified) { - result.lastModified = new Date(xmlobj.LastModified) - } - - return result -} - // parse XML response for bucket notification export function parseBucketNotification(xml) { var result = {