From a95a2b855c92b724ac26952483bab91556fd9feb Mon Sep 17 00:00:00 2001 From: Csaky Date: Fri, 13 Dec 2024 15:30:25 -0800 Subject: [PATCH] CopyVersion endpoint Copies a previous version of an object and places on top of the version 'stack'. If no version is provided to copy, the latest existing version will be copied. Input validation Documented in API spec Fixed a bug with syntax for calling S3 copyObjectCOmmand --- app/src/controllers/object.js | 77 +++++++++++++++++++++++++++++++++++ app/src/docs/v1.api-spec.yaml | 22 ++++++++++ app/src/routes/v1/object.js | 9 +++- app/src/services/storage.js | 3 +- app/src/services/version.js | 33 ++++++++------- app/src/validators/object.js | 10 +++++ 6 files changed, 136 insertions(+), 18 deletions(-) diff --git a/app/src/controllers/object.js b/app/src/controllers/object.js index ff1c7b4e..4dd6f72e 100644 --- a/app/src/controllers/object.js +++ b/app/src/controllers/object.js @@ -716,6 +716,83 @@ const controller = { } }, + /** + * @function copyVersion + * Copies a previous version of an object and places on top of the version 'stack'. + * If no version is provided to copy, the latest existing version will be copied. + * @param {object} req Express request object + * @param {object} res Express response object + * @param {function} next The next callback function + * @returns {function} Express middleware function + */ + async copyVersion(req, res, next) { + try { + const bucketId = req.currentObject?.bucketId; + const objId = addDashesToUuid(req.params.objectId); + const objPath = req.currentObject?.path; + const userId = await userService.getCurrentUserId(getCurrentIdentity(req.currentUser, SYSTEM_USER)); + + // Source S3 Version to copy from + const sourceS3VersionId = await getS3VersionId( + undefined, addDashesToUuid(req.query.versionId), objId + ); + + const source = await storageService.headObject({ filePath: objPath, s3VersionId: sourceS3VersionId, bucketId }); + + if (source.ContentLength > MAXCOPYOBJECTLENGTH) { + throw new Error('Cannot copy an object larger than 5GB'); + } + // get existing tags on source object, eg: { 'animal': 'bear', colour': 'black' } + const sourceObject = await storageService.getObjectTagging({ + filePath: objPath, + s3VersionId: sourceS3VersionId, + bucketId: bucketId + }); + const sourceTags = Object.assign({}, + ...(sourceObject.TagSet?.map(item => ({ [item.Key]: item.Value })) ?? []) + ); + + // create new version + const data = { + bucketId: bucketId, + copySource: objPath, + filePath: objPath, + metadata: source.Metadata, + tags: sourceTags, + metadataDirective: MetadataDirective.REPLACE, + taggingDirective: TaggingDirective.REPLACE, + s3VersionId: sourceS3VersionId + }; + const s3Response = await storageService.copyObject(data); + + // update COMS database + await utils.trxWrapper(async (trx) => { + // create or update version (if a non-versioned object) + const version = s3Response.VersionId ? + await versionService.copy( + sourceS3VersionId, s3Response.VersionId, objId, s3Response.CopyObjectResult?.ETag, + s3Response.CopyObjectResult?.LastModified, userId, trx + ) : + await versionService.update({ + ...data, + id: objId, + etag: s3Response.CopyObjectResult?.ETag, + isLatest: true, + lastModifiedDate: s3Response.CopyObjectResult?.LastModified + ? new Date(s3Response.CopyObjectResult?.LastModified).toISOString() : undefined + }, userId, trx); + // add metadata to version in DB + await metadataService.associateMetadata(version.id, getKeyValue(data.metadata), userId, trx); + // add tags to new version in DB + await tagService.associateTags(version.id, getKeyValue(data.tags), userId, trx); + }); + + res.status(204).end(); + } catch (e) { + next(errorToProblem(SERVICE, e)); + } + }, + /** * @function listObjectVersion * List all versions of the object diff --git a/app/src/docs/v1.api-spec.yaml b/app/src/docs/v1.api-spec.yaml index 60cc5886..f74192cc 100644 --- a/app/src/docs/v1.api-spec.yaml +++ b/app/src/docs/v1.api-spec.yaml @@ -652,6 +652,28 @@ paths: default: $ref: "#/components/responses/Error" /object/{objectId}/version: + put: + summary: Copy a version to become latest + description: >- + Copies a previous version of an object and places on top of the version 'stack'. + If no version is provided to copy, the latest existing version will be copied. + operationId: copyVersion + tags: + - Version + parameters: + - $ref: "#/components/parameters/Path-ObjectId" + - $ref: "#/components/parameters/Query-VersionId" + responses: + "204": + $ref: "#/components/responses/NoContent" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: "#/components/responses/Error" get: summary: List versions for an object description: >- diff --git a/app/src/routes/v1/object.js b/app/src/routes/v1/object.js index f6f731ed..f9d5309f 100644 --- a/app/src/routes/v1/object.js +++ b/app/src/routes/v1/object.js @@ -41,7 +41,7 @@ router.head('/:objectId', objectValidator.headObject, currentObject, hasPermissi router.get('/:objectId', helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }), objectValidator.readObject, currentObject, hasPermission(Permissions.READ), (req, res, next) => { - // TODO: Add validation to reject unexpected query parameters + // TODO: Add validation to reject unexpected query parameters objectController.readObject(req, res, next); } ); @@ -67,6 +67,13 @@ router.get('/:objectId/version', requireSomeAuth, objectValidator.listObjectVers } ); +/** creates a new version of an object using either a specified version or latest as the source */ +router.put('/:objectId/version', objectValidator.copyVersion, + currentObject, hasPermission(Permissions.UPDATE), (req, res, next) => { + objectController.copyVersion(req, res, next); + }); + + /** Sets the public flag of an object */ router.patch('/:objectId/public', requireSomeAuth, objectValidator.togglePublic, currentObject, hasPermission(Permissions.MANAGE), (req, res, next) => { diff --git a/app/src/services/storage.js b/app/src/services/storage.js index 279efacc..f00b8b6e 100644 --- a/app/src/services/storage.js +++ b/app/src/services/storage.js @@ -89,12 +89,11 @@ const objectStorageService = { const data = await utils.getBucket(bucketId); const params = { Bucket: data.bucket, - CopySource: `${data.bucket}/${copySource}`, + CopySource: `${data.bucket}/${copySource}?versionId=${s3VersionId}`, Key: filePath, Metadata: metadata, MetadataDirective: metadataDirective, TaggingDirective: taggingDirective, - VersionId: s3VersionId }; if (tags) { diff --git a/app/src/services/version.js b/app/src/services/version.js index 865e8703..f6cfae5e 100644 --- a/app/src/services/version.js +++ b/app/src/services/version.js @@ -301,23 +301,26 @@ const service = { filePath: object.path, bucketId: object.bucketId }); - const latestS3VersionId = s3Versions.DeleteMarkers - .concat(s3Versions.Versions) - .filter((v) => v.IsLatest)[0].VersionId; - // get same version from COMS db - const current = await Version.query(trx) - .first() - .where({ objectId: objectId, s3VersionId: latestS3VersionId }) - .throwIfNotFound(); - let updated; - // update as latest if not already and fetch - if (!current.isLatest) { - updated = await Version.query(trx) - .updateAndFetchById(current.id, { isLatest: true }); + let updated, current; + if(s3Versions.DeleteMarkers.concat(s3Versions.Versions).length > 0){ + const latestS3VersionId = s3Versions.DeleteMarkers + .concat(s3Versions.Versions) + .filter((v) => v.IsLatest)[0].VersionId; + // get same version from COMS db + current = await Version.query(trx) + .first() + .where({ objectId: objectId, s3VersionId: latestS3VersionId }) + .throwIfNotFound(); + + // update as latest if not already and fetch + if (!current.isLatest) { + updated = await Version.query(trx) + .updateAndFetchById(current.id, { isLatest: true }); + } + // set other versions in COMS db to isLatest=false + await service.removeDuplicateLatest(current.id, current.objectId, trx); } - // set other versions in COMS db to isLatest=false - await service.removeDuplicateLatest(current.id, current.objectId, trx); if (!etrx) await trx.commit(); return Promise.resolve(updated ?? current); diff --git a/app/src/validators/object.js b/app/src/validators/object.js index 497f13be..bf3c7988 100644 --- a/app/src/validators/object.js +++ b/app/src/validators/object.js @@ -91,6 +91,15 @@ const schema = { }) }, + copyVersion: { + params: Joi.object({ + objectId: type.uuidv4 + }), + query: Joi.object({ + versionId: scheme.guid + }) + }, + readObject: { params: Joi.object({ objectId: type.uuidv4 @@ -190,6 +199,7 @@ const validator = { fetchTags: validate(schema.fetchTags), headObject: validate(schema.headObject), listObjectVersion: validate(schema.listObjectVersion), + copyVersion: validate(schema.copyVersion), readObject: validate(schema.readObject), replaceMetadata: validate(schema.replaceMetadata), replaceTags: validate(schema.replaceTags),