diff --git a/package-lock.json b/package-lock.json index d6a35f1..adb72c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@types/express": "^4.17.13", "@types/jsrsasign": "^10.2.1", "@types/mocha": "^9.1.0", + "@types/mock-fs": "^4.13.1", "@types/node": "^17.0.23", "@types/swagger-ui-express": "^4.1.3", "@types/uuid": "^8.3.4", @@ -34,6 +35,7 @@ "@types/yamljs": "^0.2.31", "chai": "^4.3.6", "mocha": "^9.2.2", + "mock-fs": "^5.1.4", "moment": "^2.29.4", "nyc": "^15.1.0", "rimraf": "^3.0.2", @@ -743,6 +745,15 @@ "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", "dev": true }, + "node_modules/@types/mock-fs": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.1.tgz", + "integrity": "sha512-m6nFAJ3lBSnqbvDZioawRvpLXSaPyn52Srf7OfzjubYbYX8MTUdIgDxQl0wEapm4m/pNYSd9TXocpQ0TvZFlYA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "17.0.27", "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.27.tgz", @@ -2523,6 +2534,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/mock-fs": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.1.4.tgz", + "integrity": "sha512-sudhLjCjX37qWIcAlIv1OnAxB2wI4EmXByVuUjILh1rKGNGpGU8GNnzw+EAbrhdpBe0TL/KONbK1y3RXZk8SxQ==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", @@ -4531,6 +4551,15 @@ "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", "dev": true }, + "@types/mock-fs": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.1.tgz", + "integrity": "sha512-m6nFAJ3lBSnqbvDZioawRvpLXSaPyn52Srf7OfzjubYbYX8MTUdIgDxQl0wEapm4m/pNYSd9TXocpQ0TvZFlYA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/node": { "version": "17.0.27", "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.27.tgz", @@ -5857,6 +5886,12 @@ } } }, + "mock-fs": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.1.4.tgz", + "integrity": "sha512-sudhLjCjX37qWIcAlIv1OnAxB2wI4EmXByVuUjILh1rKGNGpGU8GNnzw+EAbrhdpBe0TL/KONbK1y3RXZk8SxQ==", + "dev": true + }, "moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", diff --git a/package.json b/package.json index 6f15844..bf5177d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/express": "^4.17.13", "@types/jsrsasign": "^10.2.1", "@types/mocha": "^9.1.0", + "@types/mock-fs": "^4.13.1", "@types/node": "^17.0.23", "@types/swagger-ui-express": "^4.1.3", "@types/uuid": "^8.3.4", @@ -45,6 +46,7 @@ "@types/yamljs": "^0.2.31", "chai": "^4.3.6", "mocha": "^9.2.2", + "mock-fs": "^5.1.4", "moment": "^2.29.4", "nyc": "^15.1.0", "rimraf": "^3.0.2", diff --git a/src/handlers/blobs.ts b/src/handlers/blobs.ts index 8b4addd..68806c5 100644 --- a/src/handlers/blobs.ts +++ b/src/handlers/blobs.ts @@ -59,7 +59,7 @@ export const storeBlob = async (file: IFile, filePath: string) => { cb(); } }); - file.readableStream.on('error', err => { + file.readableStream.on('error', err => { reject(err); }); file.readableStream.pipe(hashCalculator).pipe(writeStream); @@ -89,12 +89,12 @@ export const deliverBlob = async ({ blobPath, recipientID, recipientURL, request const stream = createReadStream(resolvedFilePath); const formData = new FormData(); let sender = peerID; - if(senderDestination !== undefined) { + if (senderDestination !== undefined) { formData.append('senderDestination', senderDestination); sender += '/' + senderDestination } let recipient = recipientID; - if(recipientDestination !== undefined) { + if (recipientDestination !== undefined) { formData.append('recipientDestination', recipientDestination); recipient += '/' + recipientDestination; } @@ -133,6 +133,52 @@ export const deliverBlob = async ({ blobPath, recipientID, recipientURL, request } }; +export const deleteBlob = async (filePath: string) => { + const resolvedFilePath = path.join(utils.constants.DATA_DIRECTORY, utils.constants.BLOBS_SUBDIRECTORY, filePath); + const resolvedFileMetadataPath = path.join(utils.constants.DATA_DIRECTORY, utils.constants.BLOBS_SUBDIRECTORY, filePath + utils.constants.METADATA_SUFFIX); + log.debug(`Deleting blob: ${resolvedFilePath} with metadata: ${resolvedFileMetadataPath}`); + + let tempFileCopy: Buffer | null = null; + let tempMetadataCopy = null; + let metadata: IMetadata | null = null; + + try { + if ((await utils.fileExists(resolvedFilePath))) { + // Copy to tmp files + tempFileCopy = await fs.readFile(resolvedFilePath); + // Attempt file deletion + await fs.rm(resolvedFilePath); + // Check if deletion succeeded before proceeding to delete the metadata + if (await utils.fileExists(resolvedFilePath)) { + throw new RequestError(`Blob deletion failed`); + } + } + + if ((await utils.fileExists(resolvedFileMetadataPath))) { + tempMetadataCopy = await fs.readFile(resolvedFileMetadataPath); + // Retrieve metadata to return with response + metadata = await retrieveMetadata(filePath); + await fs.rm(resolvedFileMetadataPath); + if (await utils.fileExists(resolvedFileMetadataPath)) { + throw new RequestError(`Blob metadata deletion failed`); + } + } + return metadata; + } + catch (err) { + log.error(`Error while attempting to delete Blob: ${err}`); + + // Restore any deleted files + if (tempFileCopy && !(await utils.fileExists(resolvedFilePath))) { + await fs.writeFile(resolvedFilePath, tempFileCopy); + } + if (tempMetadataCopy && !(await utils.fileExists(resolvedFileMetadataPath))) { + await fs.writeFile(resolvedFileMetadataPath, tempMetadataCopy); + } + } + +} + export const retrieveMetadata = async (filePath: string) => { const resolvedFilePath = path.join(utils.constants.DATA_DIRECTORY, utils.constants.BLOBS_SUBDIRECTORY, filePath + utils.constants.METADATA_SUFFIX); if (!(await utils.fileExists(resolvedFilePath))) { @@ -141,7 +187,7 @@ export const retrieveMetadata = async (filePath: string) => { try { const metadataString = await fs.readFile(resolvedFilePath); return JSON.parse(metadataString.toString()) as IMetadata; - } catch(err) { + } catch (err) { throw new RequestError(`Invalid blob`); } }; @@ -157,3 +203,4 @@ export const upsertMetadata = async (filePath: string, hash: string, size: numbe await fs.writeFile(resolvedFilePath, JSON.stringify(metadata)); return metadata; }; + diff --git a/src/routers/api.ts b/src/routers/api.ts index b549ba6..2f3efaf 100644 --- a/src/routers/api.ts +++ b/src/routers/api.ts @@ -239,6 +239,19 @@ router.put('/blobs/*', async (req: Request, res, next) => { } }); +router.delete('/blobs/*', async (req: Request, res, next) => { + try { + const blobPath = `/${req.params[0]}`; + if (!utils.regexp.FILE_KEY.test(blobPath) || utils.regexp.CONSECUTIVE_DOTS.test(blobPath)) { + throw new RequestError('Invalid path', 400); + } + const metadata = await blobsHandler.deleteBlob(blobPath); + res.send(metadata); + } catch (err) { + next(err); + } +}); + router.post('/transfers', async (req, res, next) => { try { if (req.body.path === undefined) { diff --git a/src/swagger.yaml b/src/swagger.yaml index 8bc77d1..4000f3b 100644 --- a/src/swagger.yaml +++ b/src/swagger.yaml @@ -240,6 +240,23 @@ application/json: schema: $ref: '#/components/schemas/Error' + delete: + tags: + - Blobs + description: Delete blob + responses: + '200': + description: Blob metadata + content: + application/json: + schema: + $ref: '#/components/schemas/BlobMetadata' + '500': + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /transfers: post: tags: @@ -365,6 +382,17 @@ properties: hash: type: string + BlobMetadata: + type: object + required: + - hash + properties: + hash: + type: string + size: + type: number + lastUpdate: + type: number Transfer: type: object required: diff --git a/test/handlers/blobs.test.ts b/test/handlers/blobs.test.ts new file mode 100644 index 0000000..de4321c --- /dev/null +++ b/test/handlers/blobs.test.ts @@ -0,0 +1,69 @@ +import * as chai from 'chai'; +import { expect } from 'chai'; +import sinonChai from 'sinon-chai'; +import * as blobs from '../../src/handlers/blobs'; +import { setLogLevel } from '../../src/lib/logger'; +import * as utils from '../../src/lib/utils'; +import path from 'path'; +import mockFs from 'mock-fs'; + +chai.use(sinonChai); + +let mockFilesystem = { + 'some/other/path': {/** another empty directory */ }, +}; +setLogLevel('debug'); + +afterEach(() => mockFs.restore()); + +describe('blobs', () => { + it('deletes both blob file and metadata successfully when both exist', async () => { + const testBlobPath = path.join(utils.constants.DATA_DIRECTORY, utils.constants.BLOBS_SUBDIRECTORY); + mockFilesystem[testBlobPath as keyof typeof mockFilesystem] = { + 'test-blob': 'file content here', + 'test-blob.metadata.json': JSON.stringify({ + hash: 'testHash', + lastUpdate: 123, + size: 10 + }) + }; + + mockFs(mockFilesystem); + const metadata = await blobs.deleteBlob('test-blob'); + + expect(metadata?.size).to.equal(10); + expect(metadata?.hash).to.equal('testHash'); + expect(metadata?.lastUpdate).to.equal(123); + }); + + it('deletes metadata that exists when file does not exist', async () => { + const testBlobPath = path.join(utils.constants.DATA_DIRECTORY, utils.constants.BLOBS_SUBDIRECTORY); + mockFilesystem[testBlobPath as keyof typeof mockFilesystem] = { + 'test-blob.metadata.json': JSON.stringify({ + hash: 'testHash', + lastUpdate: 123, + size: 10 + }) + }; + + mockFs(mockFilesystem); + const metadata = await blobs.deleteBlob('test-blob'); + + expect(metadata?.size).to.equal(10); + expect(metadata?.hash).to.equal('testHash'); + expect(metadata?.lastUpdate).to.equal(123); + }); + + it('deletes blob file successfully even if metadata does not exist', async () => { + const testBlobPath = path.join(utils.constants.DATA_DIRECTORY, utils.constants.BLOBS_SUBDIRECTORY); + mockFilesystem[testBlobPath as keyof typeof mockFilesystem] = { + 'test-blob': 'file content here', + }; + + mockFs(mockFilesystem); + const metadata = await blobs.deleteBlob('test-blob'); + + expect(metadata).to.be.null; + }); + +});