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),