From b92e90d4fd2791f57b3085dc60d43b83a0e0355e Mon Sep 17 00:00:00 2001 From: Kristian Binau Date: Sat, 21 Oct 2023 17:07:26 +0200 Subject: [PATCH 1/9] #42 - Make it clearer that TTL is in seconds --- src/plugins/redis.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/redis.ts b/src/plugins/redis.ts index 20d2b2a..1fb2eff 100644 --- a/src/plugins/redis.ts +++ b/src/plugins/redis.ts @@ -45,7 +45,7 @@ export default fastifyPlugin( }, ); - redis.remember = async (key, ttl, callback) => { + redis.remember = async (key, ttl_seconds, callback) => { let value = await redis.get(key); if (value !== null) { @@ -54,14 +54,14 @@ export default fastifyPlugin( value = await callback(); - await redis.setex(key, ttl, value); + await redis.setex(key, ttl_seconds, value); return value; }; - redis.rememberJSON = async (key, ttl, callback) => { + redis.rememberJSON = async (key, ttl_seconds, callback) => { return JSON.parse( - await redis.remember(key, ttl, async () => { + await redis.remember(key, ttl_seconds, async () => { return JSON.stringify(await callback()); }), ); From 4fc351c147b45f42de78a3c10ad63356fedbcf1b Mon Sep 17 00:00:00 2001 From: Kristian Binau Date: Sat, 21 Oct 2023 17:11:41 +0200 Subject: [PATCH 2/9] #42 - Refactored access service to make it possible to reduce repeat calls to db --- src/modules/item/blob/blob.controller.ts | 8 ++++---- src/modules/item/docs/docs.controller.ts | 8 ++++---- src/modules/item/folder/folder.controller.ts | 10 +++++----- src/modules/item/item.controller.ts | 10 ++++++---- .../item/sharing/__test__/access.service.test.ts | 8 ++++---- src/modules/item/sharing/access.service.ts | 9 +++++++-- src/modules/item/sharing/sharing.controller.ts | 8 ++++---- src/modules/item/shortcut/shortcut.controller.ts | 10 +++++----- src/modules/item/starred/starred.controller.ts | 2 +- 9 files changed, 40 insertions(+), 33 deletions(-) diff --git a/src/modules/item/blob/blob.controller.ts b/src/modules/item/blob/blob.controller.ts index 916397f..2babdb9 100644 --- a/src/modules/item/blob/blob.controller.ts +++ b/src/modules/item/blob/blob.controller.ts @@ -23,7 +23,7 @@ export default class BlobController { try { const blob = await this.blobService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(blob.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(blob.id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -42,7 +42,7 @@ export default class BlobController { try { const blob = await this.blobService.getByItemId(request.body.id); - if (!(await this.accessService.hasAccessToItem(blob.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(blob.id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -146,7 +146,7 @@ export default class BlobController { if ( clientPayloadObject.parentId !== null && - !(await this.accessService.hasAccessToItem( + !(await this.accessService.hasAccessToItemId( clientPayloadObject.parentId, accessTokenPayload.sub, )) @@ -176,7 +176,7 @@ export default class BlobController { try { const blob = await this.blobService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(blob.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(blob.id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/docs/docs.controller.ts b/src/modules/item/docs/docs.controller.ts index 7f7f866..5291dc7 100644 --- a/src/modules/item/docs/docs.controller.ts +++ b/src/modules/item/docs/docs.controller.ts @@ -23,7 +23,7 @@ export default class DocsController { try { const docs = await this.docsService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(docs.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(docs.id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -42,7 +42,7 @@ export default class DocsController { try { const docs = await this.docsService.getByItemId(request.body.id); - if (!(await this.accessService.hasAccessToItem(docs.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(docs.id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -66,7 +66,7 @@ export default class DocsController { if ( request.body.parentId !== null && request.body.parentId !== undefined && - !(await this.accessService.hasAccessToItem(request.body.parentId, request.user.sub)) + !(await this.accessService.hasAccessToItemId(request.body.parentId, request.user.sub)) ) { throw new UnauthorizedError('error.unauthorized'); } @@ -95,7 +95,7 @@ export default class DocsController { try { const docs = await this.docsService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(docs.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(docs.id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/folder/folder.controller.ts b/src/modules/item/folder/folder.controller.ts index 15b5926..59db63b 100644 --- a/src/modules/item/folder/folder.controller.ts +++ b/src/modules/item/folder/folder.controller.ts @@ -23,7 +23,7 @@ export default class FolderController { try { const folder = await this.folderService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(folder.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(folder.id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -42,14 +42,14 @@ export default class FolderController { try { const folder = await this.folderService.getByItemId(request.body.id); - if (!(await this.accessService.hasAccessToItem(folder.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(folder.id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } if ( request.body.parentId !== null && request.body.parentId !== undefined && - !(await this.accessService.hasAccessToItem(request.body.parentId, request.user.sub)) + !(await this.accessService.hasAccessToItemId(request.body.parentId, request.user.sub)) ) { throw new UnauthorizedError('error.unauthorized'); } @@ -74,7 +74,7 @@ export default class FolderController { if ( request.body.parentId !== null && request.body.parentId !== undefined && - !(await this.accessService.hasAccessToItem(request.body.parentId, request.user.sub)) + !(await this.accessService.hasAccessToItemId(request.body.parentId, request.user.sub)) ) { throw new UnauthorizedError('error.unauthorized'); } @@ -103,7 +103,7 @@ export default class FolderController { try { const folder = await this.folderService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(folder.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(folder.id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/item.controller.ts b/src/modules/item/item.controller.ts index 739adf2..9dac1c4 100644 --- a/src/modules/item/item.controller.ts +++ b/src/modules/item/item.controller.ts @@ -42,7 +42,9 @@ export default class ItemController { reply: FastifyReply, ) { try { - if (!(await this.accessService.hasAccessToItem(request.params.parentId, request.user.sub))) { + if ( + !(await this.accessService.hasAccessToItemId(request.params.parentId, request.user.sub)) + ) { throw new UnauthorizedError('error.unauthorized'); } @@ -77,7 +79,7 @@ export default class ItemController { try { const id = Number.parseInt(request.params.id); - if (!(await this.accessService.hasAccessToItem(id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -98,7 +100,7 @@ export default class ItemController { try { const id = Number.parseInt(request.params.id); - if (!(await this.accessService.hasAccessToItem(id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -117,7 +119,7 @@ export default class ItemController { reply: FastifyReply, ) { try { - if (!(await this.accessService.hasAccessToItem(request.params.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(request.params.id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/sharing/__test__/access.service.test.ts b/src/modules/item/sharing/__test__/access.service.test.ts index d1b9a84..5a79f5e 100644 --- a/src/modules/item/sharing/__test__/access.service.test.ts +++ b/src/modules/item/sharing/__test__/access.service.test.ts @@ -43,7 +43,7 @@ describe('ItemService', () => { mimeType: 'text/plain', }); - const hasAccessToItem = await accessService.hasAccessToItem(createdItem.id, user.id); + const hasAccessToItem = await accessService.hasAccessToItemId(createdItem.id, user.id); expect(hasAccessToItem).toBeTruthy(); }); @@ -63,7 +63,7 @@ describe('ItemService', () => { otherUser.id, ); - const hasAccessToItem = await accessService.hasAccessToItem(createdItem.id, user.id); + const hasAccessToItem = await accessService.hasAccessToItemId(createdItem.id, user.id); expect(hasAccessToItem).toBeTruthy(); }); @@ -76,13 +76,13 @@ describe('ItemService', () => { mimeType: 'text/plain', }); - const hasAccessToItem = await accessService.hasAccessToItem(createdItem.id, user.id); + const hasAccessToItem = await accessService.hasAccessToItemId(createdItem.id, user.id); expect(hasAccessToItem).toBeFalsy(); }); it("should throw error, when item doesn't exist", async () => { - await expect(accessService.hasAccessToItem(1234, user.id)).rejects.toThrow(); + await expect(accessService.hasAccessToItemId(1234, user.id)).rejects.toThrow(); }); }); }); diff --git a/src/modules/item/sharing/access.service.ts b/src/modules/item/sharing/access.service.ts index f871b73..3b2545d 100644 --- a/src/modules/item/sharing/access.service.ts +++ b/src/modules/item/sharing/access.service.ts @@ -1,5 +1,6 @@ import SharingService from './sharing.service'; import ItemService from '../item.service'; +import { Item } from '../item.schema'; export default class AccessService { private itemService: ItemService; @@ -10,15 +11,19 @@ export default class AccessService { this.sharingService = sharingService; } - public async hasAccessToItem(itemId: number, userId: number): Promise { + public async hasAccessToItemId(itemId: number, userId: number): Promise { const item = await this.itemService.getById(itemId); + return await this.hasAccessToItem(item, userId); + } + + public async hasAccessToItem(item: Item, userId: number): Promise { if (item.ownerId === userId) { return true; } try { - await this.sharingService.getByItemIdAndUserId(itemId, userId); + await this.sharingService.getByItemIdAndUserId(item.id, userId); return true; } catch (e) { diff --git a/src/modules/item/sharing/sharing.controller.ts b/src/modules/item/sharing/sharing.controller.ts index 7d0d44d..86cca8b 100644 --- a/src/modules/item/sharing/sharing.controller.ts +++ b/src/modules/item/sharing/sharing.controller.ts @@ -29,7 +29,7 @@ export default class SharingController { try { const sharing = await this.sharingService.getById(request.params.id); - if (!(await this.accessService.hasAccessToItem(sharing.itemId, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(sharing.itemId, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -48,7 +48,7 @@ export default class SharingController { try { const sharing = await this.sharingService.getById(request.body.id); - if (!(await this.accessService.hasAccessToItem(sharing.itemId, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(sharing.itemId, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -67,7 +67,7 @@ export default class SharingController { reply: FastifyReply, ) { try { - if (!(await this.accessService.hasAccessToItem(request.body.itemId, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(request.body.itemId, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -93,7 +93,7 @@ export default class SharingController { try { const sharing = await this.sharingService.getById(request.params.id); - if (!(await this.accessService.hasAccessToItem(sharing.itemId, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(sharing.itemId, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/shortcut/shortcut.controller.ts b/src/modules/item/shortcut/shortcut.controller.ts index e02fd58..8a77790 100644 --- a/src/modules/item/shortcut/shortcut.controller.ts +++ b/src/modules/item/shortcut/shortcut.controller.ts @@ -23,7 +23,7 @@ export default class ShortcutController { try { const shortcut = await this.shortcutService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(shortcut.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(shortcut.id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -42,14 +42,14 @@ export default class ShortcutController { try { const shortcut = await this.shortcutService.getByItemId(request.body.id); - if (!(await this.accessService.hasAccessToItem(shortcut.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(shortcut.id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } if ( request.body.parentId !== null && request.body.parentId !== undefined && - !(await this.accessService.hasAccessToItem(request.body.parentId, request.user.sub)) + !(await this.accessService.hasAccessToItemId(request.body.parentId, request.user.sub)) ) { throw new UnauthorizedError('error.unauthorized'); } @@ -74,7 +74,7 @@ export default class ShortcutController { if ( request.body.parentId !== null && request.body.parentId !== undefined && - !(await this.accessService.hasAccessToItem(request.body.parentId, request.user.sub)) + !(await this.accessService.hasAccessToItemId(request.body.parentId, request.user.sub)) ) { throw new UnauthorizedError('error.unauthorized'); } @@ -103,7 +103,7 @@ export default class ShortcutController { try { const shortcut = await this.shortcutService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(shortcut.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(shortcut.id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/starred/starred.controller.ts b/src/modules/item/starred/starred.controller.ts index e333638..542d3ed 100644 --- a/src/modules/item/starred/starred.controller.ts +++ b/src/modules/item/starred/starred.controller.ts @@ -20,7 +20,7 @@ export default class StarredController { reply: FastifyReply, ) { try { - if (!(await this.accessService.hasAccessToItem(request.body.itemId, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(request.body.itemId, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } From 7c23be2d433b3f7ce5407f67cbe0fe2ef76e1b9c Mon Sep 17 00:00:00 2001 From: Kristian Binau Date: Sat, 21 Oct 2023 17:18:15 +0200 Subject: [PATCH 3/9] #42 - Fix Critial Vulnerability --- package-lock.json | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 86262db..4190709 100644 --- a/package-lock.json +++ b/package-lock.json @@ -203,12 +203,12 @@ } }, "node_modules/@babel/generator": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.15.tgz", - "integrity": "sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", "dev": true, "dependencies": { - "@babel/types": "^7.22.15", + "@babel/types": "^7.23.0", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -252,13 +252,13 @@ } }, "node_modules/@babel/helper-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", - "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "dependencies": { - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" @@ -458,9 +458,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.22.16", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.16.tgz", - "integrity": "sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -673,19 +673,19 @@ } }, "node_modules/@babel/traverse": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.20.tgz", - "integrity": "sha512-eU260mPZbU7mZ0N+X10pxXhQFMGTeLb9eFS0mxehS8HZp9o1uSnFeWQuG1UPrlxgA7QoUzFhOnilHDp0AXCyHw==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", "dev": true, "dependencies": { "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.22.15", + "@babel/generator": "^7.23.0", "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.22.5", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.16", - "@babel/types": "^7.22.19", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -703,13 +703,13 @@ } }, "node_modules/@babel/types": { - "version": "7.22.19", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.19.tgz", - "integrity": "sha512-P7LAw/LbojPzkgp5oznjE6tQEIWbp4PkkfrZDINTro9zgBRtI324/EYsiSI7lhPbpIQ+DCeR2NNmMWANGGfZsg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.19", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { From 0c5139a34feb207c0da580aaa7493c34998da01c Mon Sep 17 00:00:00 2001 From: Kristian Binau Date: Sat, 21 Oct 2023 17:55:43 +0200 Subject: [PATCH 4/9] #42 - Update code to use the new method from access service to reduce repeat calls --- src/modules/item/blob/blob.controller.ts | 6 +- src/modules/item/docs/docs.controller.ts | 6 +- src/modules/item/folder/folder.controller.ts | 6 +- .../sharing/__test__/access.service.test.ts | 87 ++++++++++++++++++- src/modules/item/sharing/access.service.ts | 2 +- .../item/shortcut/shortcut.controller.ts | 6 +- 6 files changed, 97 insertions(+), 16 deletions(-) diff --git a/src/modules/item/blob/blob.controller.ts b/src/modules/item/blob/blob.controller.ts index 2babdb9..d850e20 100644 --- a/src/modules/item/blob/blob.controller.ts +++ b/src/modules/item/blob/blob.controller.ts @@ -23,7 +23,7 @@ export default class BlobController { try { const blob = await this.blobService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItemId(blob.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(blob, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -42,7 +42,7 @@ export default class BlobController { try { const blob = await this.blobService.getByItemId(request.body.id); - if (!(await this.accessService.hasAccessToItemId(blob.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(blob, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -176,7 +176,7 @@ export default class BlobController { try { const blob = await this.blobService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItemId(blob.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(blob, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/docs/docs.controller.ts b/src/modules/item/docs/docs.controller.ts index 5291dc7..2e1d1b9 100644 --- a/src/modules/item/docs/docs.controller.ts +++ b/src/modules/item/docs/docs.controller.ts @@ -23,7 +23,7 @@ export default class DocsController { try { const docs = await this.docsService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItemId(docs.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(docs, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -42,7 +42,7 @@ export default class DocsController { try { const docs = await this.docsService.getByItemId(request.body.id); - if (!(await this.accessService.hasAccessToItemId(docs.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(docs, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -95,7 +95,7 @@ export default class DocsController { try { const docs = await this.docsService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItemId(docs.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(docs, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/folder/folder.controller.ts b/src/modules/item/folder/folder.controller.ts index 59db63b..bd0b6bd 100644 --- a/src/modules/item/folder/folder.controller.ts +++ b/src/modules/item/folder/folder.controller.ts @@ -23,7 +23,7 @@ export default class FolderController { try { const folder = await this.folderService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItemId(folder.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(folder, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -42,7 +42,7 @@ export default class FolderController { try { const folder = await this.folderService.getByItemId(request.body.id); - if (!(await this.accessService.hasAccessToItemId(folder.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(folder, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -103,7 +103,7 @@ export default class FolderController { try { const folder = await this.folderService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItemId(folder.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(folder, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/sharing/__test__/access.service.test.ts b/src/modules/item/sharing/__test__/access.service.test.ts index 5a79f5e..dac6a51 100644 --- a/src/modules/item/sharing/__test__/access.service.test.ts +++ b/src/modules/item/sharing/__test__/access.service.test.ts @@ -43,7 +43,7 @@ describe('ItemService', () => { mimeType: 'text/plain', }); - const hasAccessToItem = await accessService.hasAccessToItemId(createdItem.id, user.id); + const hasAccessToItem = await accessService.hasAccessToItem(createdItem, user.id); expect(hasAccessToItem).toBeTruthy(); }); @@ -63,7 +63,7 @@ describe('ItemService', () => { otherUser.id, ); - const hasAccessToItem = await accessService.hasAccessToItemId(createdItem.id, user.id); + const hasAccessToItem = await accessService.hasAccessToItem(createdItem, user.id); expect(hasAccessToItem).toBeTruthy(); }); @@ -76,11 +76,92 @@ describe('ItemService', () => { mimeType: 'text/plain', }); - const hasAccessToItem = await accessService.hasAccessToItemId(createdItem.id, user.id); + const hasAccessToItem = await accessService.hasAccessToItem(createdItem, user.id); expect(hasAccessToItem).toBeFalsy(); }); + it("shouldn't care if item exists", async () => { + const hasAccessToItem = await accessService.hasAccessToItem( + { + id: 123456, + name: 'Test', + mimeType: 'text/plain', + ownerId: 43, + parentId: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }, + 43, + ); + + const hasNotAccessToItem = await accessService.hasAccessToItem( + { + id: 123456, + name: 'Test', + mimeType: 'text/plain', + ownerId: 54364356, + parentId: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }, + 43, + ); + + expect(hasAccessToItem).toBeTruthy(); + expect(hasNotAccessToItem).toBeFalsy(); + }); + }); + + describe('hasAccessToItemId()', () => { + it('should return true, when owner', async () => { + const createdItem = await itemService.createItem({ + name: 'test.txt', + ownerId: user.id, + parentId: null, + mimeType: 'text/plain', + }); + + const hasAccessToItemId = await accessService.hasAccessToItemId(createdItem.id, user.id); + + expect(hasAccessToItemId).toBeTruthy(); + }); + + it('should return true, when not owned by you, but shared with you', async () => { + const createdItem = await itemService.createItem({ + name: 'test.txt', + ownerId: otherUser.id, + parentId: null, + mimeType: 'text/plain', + }); + await sharingService.createSharing( + { + itemId: createdItem.id, + userId: user.id, + }, + otherUser.id, + ); + + const hasAccessToItemId = await accessService.hasAccessToItemId(createdItem.id, user.id); + + expect(hasAccessToItemId).toBeTruthy(); + }); + + it('should return false, when not owned or shared with you', async () => { + const createdItem = await itemService.createItem({ + name: 'test.txt', + ownerId: otherUser.id, + parentId: null, + mimeType: 'text/plain', + }); + + const hasAccessToItemId = await accessService.hasAccessToItemId(createdItem.id, user.id); + + expect(hasAccessToItemId).toBeFalsy(); + }); + it("should throw error, when item doesn't exist", async () => { await expect(accessService.hasAccessToItemId(1234, user.id)).rejects.toThrow(); }); diff --git a/src/modules/item/sharing/access.service.ts b/src/modules/item/sharing/access.service.ts index 3b2545d..9664a25 100644 --- a/src/modules/item/sharing/access.service.ts +++ b/src/modules/item/sharing/access.service.ts @@ -14,7 +14,7 @@ export default class AccessService { public async hasAccessToItemId(itemId: number, userId: number): Promise { const item = await this.itemService.getById(itemId); - return await this.hasAccessToItem(item, userId); + return await this.hasAccessToItem(item, userId); } public async hasAccessToItem(item: Item, userId: number): Promise { diff --git a/src/modules/item/shortcut/shortcut.controller.ts b/src/modules/item/shortcut/shortcut.controller.ts index 8a77790..0999f46 100644 --- a/src/modules/item/shortcut/shortcut.controller.ts +++ b/src/modules/item/shortcut/shortcut.controller.ts @@ -23,7 +23,7 @@ export default class ShortcutController { try { const shortcut = await this.shortcutService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItemId(shortcut.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(shortcut, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -42,7 +42,7 @@ export default class ShortcutController { try { const shortcut = await this.shortcutService.getByItemId(request.body.id); - if (!(await this.accessService.hasAccessToItemId(shortcut.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(shortcut, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -103,7 +103,7 @@ export default class ShortcutController { try { const shortcut = await this.shortcutService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItemId(shortcut.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(shortcut, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } From 0ac11da7d6177eee1a9e2c7df7ee72e499c48f44 Mon Sep 17 00:00:00 2001 From: Kristian Binau Date: Sat, 21 Oct 2023 23:13:09 +0200 Subject: [PATCH 5/9] #42 - Add Caching to Item Controller and make invalidation work on folder updates --- src/modules/item/__test__/item.cache.test.ts | 93 ++++++++++++++++++++ src/modules/item/folder/folder.service.ts | 48 ++++++++-- src/modules/item/item.controller.ts | 35 ++++++-- src/plugins/redis.ts | 19 +++- 4 files changed, 182 insertions(+), 13 deletions(-) create mode 100644 src/modules/item/__test__/item.cache.test.ts diff --git a/src/modules/item/__test__/item.cache.test.ts b/src/modules/item/__test__/item.cache.test.ts new file mode 100644 index 0000000..5f45bc6 --- /dev/null +++ b/src/modules/item/__test__/item.cache.test.ts @@ -0,0 +1,93 @@ +import { User } from '@prisma/client'; +import UserService from '../../auth/user.service'; +import FolderService from '../folder/folder.service'; +import AuthService from '../../auth/auth.service'; +import { AuthServiceFactory, UserServiceFactory } from '../../auth/auth.factory'; +import { FolderServiceFactory } from '../folder/folder.factory'; + +describe('GET /api/item with caching', () => { + let userService: UserService; + let folderService: FolderService; + let authService: AuthService; + + let user: User; + + beforeAll(async () => { + userService = UserServiceFactory.make(); + folderService = FolderServiceFactory.make(); + authService = AuthServiceFactory.make(); + + user = await userService.createUser({ + name: 'Joe Biden the 1st', + email: 'joe@biden.com', + password: '1234', + }); + }); + + it('Should return status 200 and all items from root folder, even when updated', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + color: '#123456', + ownerId: user.id, + parentId: null, + }); + + const response = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual([ + { + id: folder.id, + name: folder.name, + color: folder.color, + parentId: folder.parentId, + ownerId: folder.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedFolder = await folderService.updateFolder({ + id: folder.id, + color: '#987654', + name: 'Folder1', + ownerId: user.id, + parentId: null, + }); + + const updatedResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedResponse.statusCode).toBe(200); + expect(updatedResponse.json()).toEqual([ + { + id: updatedFolder.id, + name: updatedFolder.name, + color: updatedFolder.color, + parentId: updatedFolder.parentId, + ownerId: updatedFolder.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + }); +}); \ No newline at end of file diff --git a/src/modules/item/folder/folder.service.ts b/src/modules/item/folder/folder.service.ts index 88e8c05..5cc4374 100644 --- a/src/modules/item/folder/folder.service.ts +++ b/src/modules/item/folder/folder.service.ts @@ -1,5 +1,7 @@ import { prisma } from '../../../plugins/prisma'; +import { redis } from '../../../plugins/redis'; import { MissingError } from '../../../utils/error'; +import { CACHE_ITEMS } from '../item.controller'; import SharingService from '../sharing/sharing.service'; import { Folder, AddFolder, UpdateFolder, ItemFolder } from './folder.schema'; @@ -32,7 +34,11 @@ export default class FolderService { await this.sharingService.syncSharingsByItemId(input.parentId, itemFolder.item.id); } - return this.formatItemFolder(itemFolder); + const folder = this.formatItemFolder(itemFolder); + + this.invalidateCachesForFolder(folder); + + return folder; } public async getByItemId(itemId: number): Promise { @@ -71,15 +77,30 @@ export default class FolderService { }, }); - return this.formatItemFolder(itemFolder); + const folder = this.formatItemFolder(itemFolder); + + this.invalidateCachesForFolder(folder); + + return folder; } public async deleteFolderByItemId(itemId: number): Promise { - await prisma.item.delete({ - where: { - id: itemId, - }, - }); + let folder: Folder; + + try { + folder = await this.getByItemId(itemId); + } catch (e) { + return; + } + + await Promise.all([ + prisma.item.delete({ + where: { + id: itemId, + }, + }), + this.invalidateCachesForFolder(folder), + ]); } private formatItemFolder(itemFolder: ItemFolder): Folder { @@ -88,4 +109,17 @@ export default class FolderService { ...itemFolder.item, }; } + + private async invalidateCachesForFolder(folder: Folder): Promise { + // Cache - Item + await redis.invalidateCaches(`${CACHE_ITEMS}:${folder.id}:*`); + + if (folder.parentId) { + // Cache - Parent + await redis.invalidateCaches(`${CACHE_ITEMS}:${folder.parentId}:*`); + } else { + // Cache - Root + await redis.invalidateCaches(`${CACHE_ITEMS}:root:${folder.ownerId}`); + } + } } diff --git a/src/modules/item/item.controller.ts b/src/modules/item/item.controller.ts index 9dac1c4..7367a41 100644 --- a/src/modules/item/item.controller.ts +++ b/src/modules/item/item.controller.ts @@ -1,9 +1,18 @@ import { FastifyReply, FastifyRequest } from 'fastify'; import ItemService from './item.service'; -import { ReadInput, itemSharingsInput, itemReadInput, itemBreadcrumbInput } from './item.schema'; +import { + ReadInput, + itemSharingsInput, + itemReadInput, + itemBreadcrumbInput, + ItemWithProperties, +} from './item.schema'; import AccessService from './sharing/access.service'; import { UnauthorizedError, errorReply } from '../../utils/error'; +export const CACHE_ITEMS = 'items:'; +const CACHE_TTL = 10000; + export default class ItemController { private itemService: ItemService; private accessService: AccessService; @@ -26,7 +35,15 @@ export default class ItemController { public async itemRootHandler(request: FastifyRequest, reply: FastifyReply) { try { - const items = await this.itemService.getByOwnerIdAndParentId(request.user.sub, null); + const items = await request.redis.rememberJSON( + `${CACHE_ITEMS}:root:${request.user.sub}`, + CACHE_TTL, + async () => { + request.log.warn('GET ROOT ITEMS'); + + return await this.itemService.getByOwnerIdAndParentId(request.user.sub, null); + }, + ); return reply.code(200).send(items); } catch (e) { @@ -48,9 +65,17 @@ export default class ItemController { throw new UnauthorizedError('error.unauthorized'); } - const items = await this.itemService.getAllOwnedAndSharredItemsByParentIdAndUserId( - request.user.sub, - request.params.parentId, + const items = await request.redis.rememberJSON( + `${CACHE_ITEMS}:${request.params.parentId}:${request.user.sub}`, + CACHE_TTL, + async () => { + request.log.warn('GET ITEMS IN ' + request.params.parentId); + + return await this.itemService.getAllOwnedAndSharredItemsByParentIdAndUserId( + request.user.sub, + request.params.parentId, + ); + }, ); return reply.code(200).send(items); diff --git a/src/plugins/redis.ts b/src/plugins/redis.ts index 1fb2eff..0d8c21e 100644 --- a/src/plugins/redis.ts +++ b/src/plugins/redis.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from 'fastify'; import fastifyPlugin from 'fastify-plugin'; -import Redis from 'ioredis'; +import Redis, { Callback, RedisKey } from 'ioredis'; import { v4 } from 'uuid'; declare module 'fastify' { @@ -70,6 +70,23 @@ export default fastifyPlugin( redis.invalidateCaches = async (...keys) => { await Promise.all( keys.map(async (key) => { + // If the key is a pattern, delete all keys matching the pattern + if (key.includes('*')) { + const stream = redis.scanStream({ + match: key, + }); + + stream.on('data', async (keys) => { + await Promise.all( + keys.map(async (key: RedisKey) => { + await redis.del(key); + }), + ); + }); + + return; + } + await redis.del(key); }), ); From 190d4714ae356eb8c8bb0e4c70b72e16e43129fa Mon Sep 17 00:00:00 2001 From: Kristian Binau Date: Sun, 22 Oct 2023 22:18:35 +0200 Subject: [PATCH 6/9] #42 - Cache gets invalidated on all changes and tests are working --- src/modules/item/__test__/item.cache.test.ts | 936 +++++++++++++++++- src/modules/item/blob/blob.service.ts | 51 +- src/modules/item/docs/docs.service.ts | 34 +- src/modules/item/folder/folder.service.ts | 24 +- src/modules/item/item.controller.ts | 6 +- src/modules/item/item.service.ts | 21 + src/modules/item/sharing/sharing.service.ts | 20 + src/modules/item/shortcut/shortcut.service.ts | 34 +- src/plugins/redis.ts | 36 +- 9 files changed, 1068 insertions(+), 94 deletions(-) diff --git a/src/modules/item/__test__/item.cache.test.ts b/src/modules/item/__test__/item.cache.test.ts index 5f45bc6..f79f4bb 100644 --- a/src/modules/item/__test__/item.cache.test.ts +++ b/src/modules/item/__test__/item.cache.test.ts @@ -2,13 +2,25 @@ import { User } from '@prisma/client'; import UserService from '../../auth/user.service'; import FolderService from '../folder/folder.service'; import AuthService from '../../auth/auth.service'; +import BlobService from '../blob/blob.service'; +import DocsService from '../docs/docs.service'; +import ShortcutService from '../shortcut/shortcut.service'; +import SharingService from '../sharing/sharing.service'; import { AuthServiceFactory, UserServiceFactory } from '../../auth/auth.factory'; import { FolderServiceFactory } from '../folder/folder.factory'; +import { BlobServiceFactory } from '../blob/blob.factory'; +import { DocsServiceFactory } from '../docs/docs.factory'; +import { ShortcutServiceFactory } from '../shortcut/shortcut.factory'; +import { SharingServiceFactory } from '../sharing/sharing.factory'; +import { Folder } from '../folder/folder.schema'; describe('GET /api/item with caching', () => { let userService: UserService; - let folderService: FolderService; let authService: AuthService; + let folderService: FolderService; + let blobService: BlobService; + let docsService: DocsService; + let shortcutService: ShortcutService; let user: User; @@ -16,6 +28,9 @@ describe('GET /api/item with caching', () => { userService = UserServiceFactory.make(); folderService = FolderServiceFactory.make(); authService = AuthServiceFactory.make(); + blobService = BlobServiceFactory.make(); + docsService = DocsServiceFactory.make(); + shortcutService = ShortcutServiceFactory.make(); user = await userService.createUser({ name: 'Joe Biden the 1st', @@ -24,17 +39,38 @@ describe('GET /api/item with caching', () => { }); }); - it('Should return status 200 and all items from root folder, even when updated', async () => { + it('Should return status 200 and all items from root folder, even when created, updated and deleted', async () => { const { accessToken } = await authService.createTokens(user.id); - const folder = await folderService.createFolder({ + /** + * + * Empty + * + */ + const emptyResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(emptyResponse.statusCode).toBe(200); + expect(emptyResponse.json()).toEqual([]); + + /** + * + * Folder + * + */ + const createdFolder = await folderService.createFolder({ name: 'Folder1', color: '#123456', ownerId: user.id, parentId: null, }); - const response = await global.fastify.inject({ + const createdFolderResponse = await global.fastify.inject({ method: 'GET', url: '/api/item', headers: { @@ -42,31 +78,31 @@ describe('GET /api/item with caching', () => { }, }); - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual([ + expect(createdFolderResponse.statusCode).toBe(200); + expect(createdFolderResponse.json()).toEqual([ { - id: folder.id, - name: folder.name, - color: folder.color, - parentId: folder.parentId, - ownerId: folder.ownerId, - mimeType: 'application/vnd.cloudstore.folder', - createdAt: expect.any(String), - deletedAt: null, - updatedAt: expect.any(String), - isStarred: false, - }, + id: createdFolder.id, + name: createdFolder.name, + color: createdFolder.color, + parentId: createdFolder.parentId, + ownerId: createdFolder.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, ]); const updatedFolder = await folderService.updateFolder({ - id: folder.id, + id: createdFolder.id, color: '#987654', name: 'Folder1', ownerId: user.id, parentId: null, }); - const updatedResponse = await global.fastify.inject({ + const updatedFolderResponse = await global.fastify.inject({ method: 'GET', url: '/api/item', headers: { @@ -74,8 +110,8 @@ describe('GET /api/item with caching', () => { }, }); - expect(updatedResponse.statusCode).toBe(200); - expect(updatedResponse.json()).toEqual([ + expect(updatedFolderResponse.statusCode).toBe(200); + expect(updatedFolderResponse.json()).toEqual([ { id: updatedFolder.id, name: updatedFolder.name, @@ -89,5 +125,861 @@ describe('GET /api/item with caching', () => { isStarred: false, }, ]); + + await folderService.deleteFolderByItemId(createdFolder.id); + + const deletedFolderResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedFolderResponse.statusCode).toBe(200); + expect(deletedFolderResponse.json()).toEqual([]); + + /** + * + * Blob + * + */ + const createdBlob = await blobService.createBlob({ + name: 'test1.txt', + blobUrl: 'https://example.com/test1.txt', + mimeType: 'text/plain', + ownerId: user.id, + parentId: null, + }); + + const createdBlobResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdBlobResponse.statusCode).toBe(200); + expect(createdBlobResponse.json()).toEqual([ + { + id: createdBlob.id, + name: createdBlob.name, + blobUrl: createdBlob.blobUrl, + parentId: createdBlob.parentId, + ownerId: createdBlob.ownerId, + mimeType: createdBlob.mimeType, + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedBlob = await blobService.updateBlob({ + id: createdBlob.id, + name: 'test2.txt', + blobUrl: 'https://example.com/test2.txt', + mimeType: 'text/plain', + ownerId: user.id, + parentId: null, + }); + + const updatedBlobResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedBlobResponse.statusCode).toBe(200); + expect(updatedBlobResponse.json()).toEqual([ + { + id: updatedBlob.id, + name: updatedBlob.name, + blobUrl: updatedBlob.blobUrl, + parentId: updatedBlob.parentId, + ownerId: updatedBlob.ownerId, + mimeType: updatedBlob.mimeType, + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await blobService.deleteBlobByItemId(createdBlob.id); + + const deletedBlobResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedBlobResponse.statusCode).toBe(200); + expect(deletedBlobResponse.json()).toEqual([]); + + /** + * + * Docs + * + */ + const createdDocs = await docsService.createDocs({ + name: 'Docs1', + text: 'Docs1 text', + ownerId: user.id, + parentId: null, + }); + + const createdDocsResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdDocsResponse.statusCode).toBe(200); + expect(createdDocsResponse.json()).toEqual([ + { + id: createdDocs.id, + name: createdDocs.name, + text: createdDocs.text, + parentId: createdDocs.parentId, + ownerId: createdDocs.ownerId, + mimeType: 'application/vnd.cloudstore.docs', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedDocs = await docsService.updateDocs({ + id: createdDocs.id, + name: 'Docs2', + text: 'Docs2 text', + ownerId: user.id, + parentId: null, + }); + + const updatedDocsResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedDocsResponse.statusCode).toBe(200); + expect(updatedDocsResponse.json()).toEqual([ + { + id: updatedDocs.id, + name: updatedDocs.name, + text: updatedDocs.text, + parentId: updatedDocs.parentId, + ownerId: updatedDocs.ownerId, + mimeType: 'application/vnd.cloudstore.docs', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await docsService.deleteDocsByItemId(createdDocs.id); + + const deletedDocsResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedDocsResponse.statusCode).toBe(200); + expect(deletedDocsResponse.json()).toEqual([]); + + /** + * + * Shortcut + * + */ + const linkedFolder = await folderService.createFolder({ + name: 'Linked Folder', + color: '#123456', + ownerId: user.id, + parentId: null, + }); + + // Set cache to make sure that shortcut invalidates cache on created. + await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + const createdShortcut = await shortcutService.createShortcut({ + name: 'Shortcut', + ownerId: user.id, + linkedItemId: linkedFolder.id, + parentId: null, + }); + + const createdShortcutResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdShortcutResponse.statusCode).toBe(200); + expect(createdShortcutResponse.json()).toEqual([ + { + id: linkedFolder.id, + name: linkedFolder.name, + parentId: linkedFolder.parentId, + ownerId: linkedFolder.ownerId, + color: linkedFolder.color, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + { + id: createdShortcut.id, + name: createdShortcut.name, + parentId: createdShortcut.parentId, + ownerId: createdShortcut.ownerId, + linkedItemId: createdShortcut.linkedItemId, + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedShortcut = await shortcutService.updateShortcut({ + id: createdShortcut.id, + name: 'Shortcut2', + ownerId: user.id, + linkedItemId: linkedFolder.id, + parentId: null, + }); + + const updatedShortcutResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedShortcutResponse.statusCode).toBe(200); + expect(updatedShortcutResponse.json()).toEqual([ + { + id: linkedFolder.id, + name: linkedFolder.name, + parentId: linkedFolder.parentId, + ownerId: linkedFolder.ownerId, + color: linkedFolder.color, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + { + id: updatedShortcut.id, + name: updatedShortcut.name, + parentId: updatedShortcut.parentId, + ownerId: updatedShortcut.ownerId, + linkedItemId: updatedShortcut.linkedItemId, + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await shortcutService.deleteShortcutByItemId(createdShortcut.id); + + const deletedShortcutResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedShortcutResponse.statusCode).toBe(200); + expect(deletedShortcutResponse.json()).toEqual([ + { + id: linkedFolder.id, + name: linkedFolder.name, + parentId: linkedFolder.parentId, + ownerId: linkedFolder.ownerId, + color: linkedFolder.color, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + }); +}); + +describe('GET /api/item/:parentId with caching', () => { + let userService: UserService; + let authService: AuthService; + let folderService: FolderService; + let blobService: BlobService; + let docsService: DocsService; + let shortcutService: ShortcutService; + + let sharingService: SharingService; + + let user: User; + let parentFolder: Folder; + let accessToken: string; + + beforeAll(async () => { + userService = UserServiceFactory.make(); + folderService = FolderServiceFactory.make(); + authService = AuthServiceFactory.make(); + blobService = BlobServiceFactory.make(); + docsService = DocsServiceFactory.make(); + shortcutService = ShortcutServiceFactory.make(); + + sharingService = SharingServiceFactory.make(); + + user = await userService.createUser({ + name: 'Joe Biden the 1st', + email: 'joe45435346354@biden.com', + password: '1234', + }); + + parentFolder = await folderService.createFolder({ + name: 'Test Folder', + color: '#123456', + ownerId: user.id, + parentId: null, + }); + + accessToken = (await authService.createTokens(user.id)).accessToken; + }); + + it('Should return status 200 and all items from folder, even when created, updated and deleted', async () => { + /** + * + * Empty + * + */ + const emptyResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(emptyResponse.statusCode).toBe(200); + expect(emptyResponse.json()).toEqual([]); + + /** + * + * Folder + * + */ + const createdFolder = await folderService.createFolder({ + name: 'Folder1', + color: '#123456', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const createdFolderResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdFolderResponse.statusCode).toBe(200); + expect(createdFolderResponse.json()).toEqual([ + { + id: createdFolder.id, + name: createdFolder.name, + color: createdFolder.color, + parentId: createdFolder.parentId, + ownerId: createdFolder.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedFolder = await folderService.updateFolder({ + id: createdFolder.id, + color: '#987654', + name: 'Folder1', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const updatedFolderResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedFolderResponse.statusCode).toBe(200); + expect(updatedFolderResponse.json()).toEqual([ + { + id: updatedFolder.id, + name: updatedFolder.name, + color: updatedFolder.color, + parentId: updatedFolder.parentId, + ownerId: updatedFolder.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await folderService.deleteFolderByItemId(createdFolder.id); + + const deletedFolderResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedFolderResponse.statusCode).toBe(200); + expect(deletedFolderResponse.json()).toEqual([]); + + /** + * + * Blob + * + */ + const createdBlob = await blobService.createBlob({ + name: 'test1.txt', + blobUrl: 'https://example.com/test1.txt', + mimeType: 'text/plain', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const createdBlobResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdBlobResponse.statusCode).toBe(200); + expect(createdBlobResponse.json()).toEqual([ + { + id: createdBlob.id, + name: createdBlob.name, + blobUrl: createdBlob.blobUrl, + parentId: createdBlob.parentId, + ownerId: createdBlob.ownerId, + mimeType: createdBlob.mimeType, + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedBlob = await blobService.updateBlob({ + id: createdBlob.id, + name: 'test2.txt', + blobUrl: 'https://example.com/test2.txt', + mimeType: 'text/plain', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const updatedBlobResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedBlobResponse.statusCode).toBe(200); + expect(updatedBlobResponse.json()).toEqual([ + { + id: updatedBlob.id, + name: updatedBlob.name, + blobUrl: updatedBlob.blobUrl, + parentId: updatedBlob.parentId, + ownerId: updatedBlob.ownerId, + mimeType: updatedBlob.mimeType, + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await blobService.deleteBlobByItemId(createdBlob.id); + + const deletedBlobResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedBlobResponse.statusCode).toBe(200); + expect(deletedBlobResponse.json()).toEqual([]); + + /** + * + * Docs + * + */ + const createdDocs = await docsService.createDocs({ + name: 'Docs1', + text: 'Docs1 text', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const createdDocsResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdDocsResponse.statusCode).toBe(200); + expect(createdDocsResponse.json()).toEqual([ + { + id: createdDocs.id, + name: createdDocs.name, + text: createdDocs.text, + parentId: createdDocs.parentId, + ownerId: createdDocs.ownerId, + mimeType: 'application/vnd.cloudstore.docs', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedDocs = await docsService.updateDocs({ + id: createdDocs.id, + name: 'Docs2', + text: 'Docs2 text', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const updatedDocsResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedDocsResponse.statusCode).toBe(200); + expect(updatedDocsResponse.json()).toEqual([ + { + id: updatedDocs.id, + name: updatedDocs.name, + text: updatedDocs.text, + parentId: updatedDocs.parentId, + ownerId: updatedDocs.ownerId, + mimeType: 'application/vnd.cloudstore.docs', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await docsService.deleteDocsByItemId(createdDocs.id); + + const deletedDocsResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedDocsResponse.statusCode).toBe(200); + expect(deletedDocsResponse.json()).toEqual([]); + + /** + * + * Shortcut + * + */ + const linkedFolder = await folderService.createFolder({ + name: 'Linked Folder', + color: '#123456', + ownerId: user.id, + parentId: parentFolder.id, + }); + + // Set cache to make sure that shortcut invalidates cache on created. + await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + const createdShortcut = await shortcutService.createShortcut({ + name: 'Shortcut', + ownerId: user.id, + linkedItemId: linkedFolder.id, + parentId: parentFolder.id, + }); + + const createdShortcutResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdShortcutResponse.statusCode).toBe(200); + expect(createdShortcutResponse.json()).toEqual([ + { + id: linkedFolder.id, + name: linkedFolder.name, + parentId: linkedFolder.parentId, + ownerId: linkedFolder.ownerId, + color: linkedFolder.color, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + { + id: createdShortcut.id, + name: createdShortcut.name, + parentId: createdShortcut.parentId, + ownerId: createdShortcut.ownerId, + linkedItemId: createdShortcut.linkedItemId, + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedShortcut = await shortcutService.updateShortcut({ + id: createdShortcut.id, + name: 'Shortcut2', + ownerId: user.id, + linkedItemId: linkedFolder.id, + parentId: parentFolder.id, + }); + + const updatedShortcutResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedShortcutResponse.statusCode).toBe(200); + expect(updatedShortcutResponse.json()).toEqual([ + { + id: linkedFolder.id, + name: linkedFolder.name, + parentId: linkedFolder.parentId, + ownerId: linkedFolder.ownerId, + color: linkedFolder.color, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + { + id: updatedShortcut.id, + name: updatedShortcut.name, + parentId: updatedShortcut.parentId, + ownerId: updatedShortcut.ownerId, + linkedItemId: updatedShortcut.linkedItemId, + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await shortcutService.deleteShortcutByItemId(createdShortcut.id); + + const deletedShortcutResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedShortcutResponse.statusCode).toBe(200); + expect(deletedShortcutResponse.json()).toEqual([ + { + id: linkedFolder.id, + name: linkedFolder.name, + parentId: linkedFolder.parentId, + ownerId: linkedFolder.ownerId, + color: linkedFolder.color, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await folderService.deleteFolderByItemId(linkedFolder.id); + }); + + it('Should clear cache when shared', async () => { + const folder = await folderService.createFolder({ + name: 'Folder1', + color: '#123456', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const otherUser = await userService.createUser({ + name: 'Joe Biden the 2nd', + email: 'share@with.me', + password: '1234', + }); + + const otherAccessToken = (await authService.createTokens(otherUser.id)).accessToken; + + // Hasn't been shared + const emptyResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + otherAccessToken, + }, + }); + + expect(emptyResponse.statusCode).toBe(401); + + // Share folder + await sharingService.createSharing( + { + itemId: parentFolder.id, + userId: otherUser.id, + }, + user.id, + ); + + const sharedResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + otherAccessToken, + }, + }); + + expect(sharedResponse.statusCode).toBe(200); + expect(sharedResponse.json()).toEqual([ + { + id: folder.id, + name: folder.name, + color: folder.color, + parentId: folder.parentId, + ownerId: folder.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + // Add folder to shared folder + const anotherFolderInShared = await folderService.createFolder({ + name: 'Folder1', + color: '#123456', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const sharedAnotherResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + otherAccessToken, + }, + }); + + expect(sharedAnotherResponse.statusCode).toBe(200); + expect(sharedAnotherResponse.json()).toEqual([ + { + id: folder.id, + name: folder.name, + color: folder.color, + parentId: folder.parentId, + ownerId: folder.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + { + id: anotherFolderInShared.id, + name: anotherFolderInShared.name, + color: anotherFolderInShared.color, + parentId: anotherFolderInShared.parentId, + ownerId: anotherFolderInShared.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); }); -}); \ No newline at end of file +}); diff --git a/src/modules/item/blob/blob.service.ts b/src/modules/item/blob/blob.service.ts index 1358cf8..cdf3738 100644 --- a/src/modules/item/blob/blob.service.ts +++ b/src/modules/item/blob/blob.service.ts @@ -6,6 +6,7 @@ import { prisma } from '../../../plugins/prisma'; import { Blob, CreateBlob, UpdateBlob, ItemBlob } from './blob.schema'; import SharingService from '../sharing/sharing.service'; import { MissingError, UnauthorizedError } from '../../../utils/error'; +import ItemService from '../item.service'; type OnUploadCompletedCallback = (body: { blob: PutBlobResult; @@ -80,7 +81,11 @@ export default class BlobService { await this.sharingService.syncSharingsByItemId(input.parentId, itemBlob.item.id); } - return this.formatItemBlob(itemBlob); + const blob = this.formatItemBlob(itemBlob); + + await ItemService.invalidateCachesForItem(blob); + + return blob; } public async updateBlob(input: UpdateBlob): Promise { @@ -101,29 +106,41 @@ export default class BlobService { }, }); - return this.formatItemBlob(itemBlob); + const blob = this.formatItemBlob(itemBlob); + + await ItemService.invalidateCachesForItem(blob); + + return blob; } public async deleteBlobByItemId(itemId: number): Promise { - const itemBlob = await prisma.item.delete({ - where: { - id: itemId, - }, - include: { - ItemBlob: true, - }, - }); - - /* istanbul ignore next */ - if (!itemBlob.ItemBlob) { - return; - } + let blob: Blob; try { - await this.deleteBlobByUrl(itemBlob.ItemBlob.blobUrl); + blob = await this.getByItemId(itemId); } catch (e) { - // Do nothing + return; } + + await Promise.all([ + prisma.item.delete({ + where: { + id: itemId, + }, + }), + ItemService.invalidateCachesForItem(blob), + async () => { + if (!blob.blobUrl) { + return; + } + + try { + await this.deleteBlobByUrl(blob.blobUrl); + } catch (e) { + // Do nothing + } + }, + ]); } public async deleteBlobByUrl(url: string | string[]): Promise { diff --git a/src/modules/item/docs/docs.service.ts b/src/modules/item/docs/docs.service.ts index 9b5659f..cce56f1 100644 --- a/src/modules/item/docs/docs.service.ts +++ b/src/modules/item/docs/docs.service.ts @@ -1,5 +1,6 @@ import { prisma } from '../../../plugins/prisma'; import { MissingError } from '../../../utils/error'; +import ItemService from '../item.service'; import SharingService from '../sharing/sharing.service'; import { Docs, AddDocs, UpdateDocs, ItemDocs } from './docs.schema'; @@ -32,7 +33,11 @@ export default class DocsService { await this.sharingService.syncSharingsByItemId(input.parentId, itemDocs.item.id); } - return this.formatitemDocs(itemDocs); + const docs = this.formatitemDocs(itemDocs); + + await ItemService.invalidateCachesForItem(docs); + + return docs; } public async getByItemId(itemId: number): Promise { @@ -71,15 +76,30 @@ export default class DocsService { }, }); - return this.formatitemDocs(itemDocs); + const docs = this.formatitemDocs(itemDocs); + + await ItemService.invalidateCachesForItem(docs); + + return docs; } public async deleteDocsByItemId(itemId: number): Promise { - await prisma.item.delete({ - where: { - id: itemId, - }, - }); + let docs: Docs; + + try { + docs = await this.getByItemId(itemId); + } catch (e) { + return; + } + + await Promise.all([ + prisma.item.delete({ + where: { + id: itemId, + }, + }), + ItemService.invalidateCachesForItem(docs), + ]); } private formatitemDocs(itemDocs: ItemDocs): Docs { diff --git a/src/modules/item/folder/folder.service.ts b/src/modules/item/folder/folder.service.ts index 5cc4374..97f9560 100644 --- a/src/modules/item/folder/folder.service.ts +++ b/src/modules/item/folder/folder.service.ts @@ -1,7 +1,6 @@ import { prisma } from '../../../plugins/prisma'; -import { redis } from '../../../plugins/redis'; import { MissingError } from '../../../utils/error'; -import { CACHE_ITEMS } from '../item.controller'; +import ItemService from '../item.service'; import SharingService from '../sharing/sharing.service'; import { Folder, AddFolder, UpdateFolder, ItemFolder } from './folder.schema'; @@ -36,7 +35,7 @@ export default class FolderService { const folder = this.formatItemFolder(itemFolder); - this.invalidateCachesForFolder(folder); + await ItemService.invalidateCachesForItem(folder); return folder; } @@ -79,13 +78,13 @@ export default class FolderService { const folder = this.formatItemFolder(itemFolder); - this.invalidateCachesForFolder(folder); + await ItemService.invalidateCachesForItem(folder); return folder; } public async deleteFolderByItemId(itemId: number): Promise { - let folder: Folder; + let folder: Folder; try { folder = await this.getByItemId(itemId); @@ -99,7 +98,7 @@ export default class FolderService { id: itemId, }, }), - this.invalidateCachesForFolder(folder), + ItemService.invalidateCachesForItem(folder), ]); } @@ -109,17 +108,4 @@ export default class FolderService { ...itemFolder.item, }; } - - private async invalidateCachesForFolder(folder: Folder): Promise { - // Cache - Item - await redis.invalidateCaches(`${CACHE_ITEMS}:${folder.id}:*`); - - if (folder.parentId) { - // Cache - Parent - await redis.invalidateCaches(`${CACHE_ITEMS}:${folder.parentId}:*`); - } else { - // Cache - Root - await redis.invalidateCaches(`${CACHE_ITEMS}:root:${folder.ownerId}`); - } - } } diff --git a/src/modules/item/item.controller.ts b/src/modules/item/item.controller.ts index 7367a41..4265397 100644 --- a/src/modules/item/item.controller.ts +++ b/src/modules/item/item.controller.ts @@ -10,7 +10,7 @@ import { import AccessService from './sharing/access.service'; import { UnauthorizedError, errorReply } from '../../utils/error'; -export const CACHE_ITEMS = 'items:'; +export const CACHE_ITEMS = 'items'; const CACHE_TTL = 10000; export default class ItemController { @@ -39,8 +39,6 @@ export default class ItemController { `${CACHE_ITEMS}:root:${request.user.sub}`, CACHE_TTL, async () => { - request.log.warn('GET ROOT ITEMS'); - return await this.itemService.getByOwnerIdAndParentId(request.user.sub, null); }, ); @@ -69,8 +67,6 @@ export default class ItemController { `${CACHE_ITEMS}:${request.params.parentId}:${request.user.sub}`, CACHE_TTL, async () => { - request.log.warn('GET ITEMS IN ' + request.params.parentId); - return await this.itemService.getAllOwnedAndSharredItemsByParentIdAndUserId( request.user.sub, request.params.parentId, diff --git a/src/modules/item/item.service.ts b/src/modules/item/item.service.ts index f12d2c8..968bc3f 100644 --- a/src/modules/item/item.service.ts +++ b/src/modules/item/item.service.ts @@ -1,5 +1,7 @@ import { prisma } from '../../plugins/prisma'; +import { redis } from '../../plugins/redis'; import { MissingError } from '../../utils/error'; +import { CACHE_ITEMS } from './item.controller'; import { CreateItem, Item, @@ -280,4 +282,23 @@ export default class ItemService { return this.formatItem(element); }); } + + public static async invalidateCachesForItem(item: Item): Promise { + await Promise.all([ + // Cache - Item + redis.invalidateCaches(`${CACHE_ITEMS}:${item.id}:*`), + // Cache - Parent + item.parentId !== null + ? redis.invalidateCaches(`${CACHE_ITEMS}:${item.parentId}:*`) + : // Cache - Root + redis.invalidateCaches(`${CACHE_ITEMS}:root:${item.ownerId}`), + ]); + } + + public static async invalidateCachesForUser(userId: number): Promise { + await Promise.all([ + // Cache - User + redis.invalidateCaches(`${CACHE_ITEMS}:*:${userId}`), + ]); + } } diff --git a/src/modules/item/sharing/sharing.service.ts b/src/modules/item/sharing/sharing.service.ts index 5f920bf..8e8c2cd 100644 --- a/src/modules/item/sharing/sharing.service.ts +++ b/src/modules/item/sharing/sharing.service.ts @@ -58,6 +58,11 @@ export default class SharingService { skipDuplicates: true, }); + await Promise.all([ + ItemService.invalidateCachesForUser(input.userId), + ItemService.invalidateCachesForUser(userId), + ]); + try { await this.getByItemIdAndUserId(input.itemId, input.userId); @@ -111,6 +116,11 @@ export default class SharingService { ], }, }); + + await Promise.all([ + ItemService.invalidateCachesForUser(input.userId), + ItemService.invalidateCachesForUser(userId), + ]); } catch (e) { // Nothing to do here } @@ -129,6 +139,8 @@ export default class SharingService { }, }); + await ItemService.invalidateCachesForUser(input.userId); + return itemSharing; } @@ -161,6 +173,8 @@ export default class SharingService { ], }, }); + + await ItemService.invalidateCachesForUser(userId); } catch (e) { // Nothing to do here } @@ -181,6 +195,12 @@ export default class SharingService { userIds.push(sharing.userId); }); + await Promise.all( + userIds.map(async (userId) => { + await ItemService.invalidateCachesForUser(userId); + }), + ); + await prisma.itemSharing.createMany({ data: userIds.map((userId) => { return { diff --git a/src/modules/item/shortcut/shortcut.service.ts b/src/modules/item/shortcut/shortcut.service.ts index d517a99..8491bc8 100644 --- a/src/modules/item/shortcut/shortcut.service.ts +++ b/src/modules/item/shortcut/shortcut.service.ts @@ -1,5 +1,6 @@ import { prisma } from '../../../plugins/prisma'; import { MissingError } from '../../../utils/error'; +import ItemService from '../item.service'; import SharingService from '../sharing/sharing.service'; import { Shortcut, AddShortcut, UpdateShortcut, ItemShortcut } from './shortcut.schema'; @@ -36,7 +37,11 @@ export default class ShortcutService { await this.sharingService.syncSharingsByItemId(input.parentId, itemShortcut.shortcutItem.id); } - return this.formatItemShortcut(itemShortcut); + const shortcut = this.formatItemShortcut(itemShortcut); + + await ItemService.invalidateCachesForItem(shortcut); + + return shortcut; } public async getByItemId(itemId: number): Promise { @@ -74,15 +79,30 @@ export default class ShortcutService { }, }); - return this.formatItemShortcut(itemShortcut); + const shortcut = this.formatItemShortcut(itemShortcut); + + await ItemService.invalidateCachesForItem(shortcut); + + return shortcut; } public async deleteShortcutByItemId(itemId: number): Promise { - await prisma.item.delete({ - where: { - id: itemId, - }, - }); + let shortcut: Shortcut; + + try { + shortcut = await this.getByItemId(itemId); + } catch (e) { + return; + } + + await Promise.all([ + prisma.item.delete({ + where: { + id: itemId, + }, + }), + ItemService.invalidateCachesForItem(shortcut), + ]); } private formatItemShortcut(itemShortcut: ItemShortcut): Shortcut { diff --git a/src/plugins/redis.ts b/src/plugins/redis.ts index 0d8c21e..4118d0c 100644 --- a/src/plugins/redis.ts +++ b/src/plugins/redis.ts @@ -27,7 +27,7 @@ export default fastifyPlugin( redis = new Redis(fastify.config.REDIS_URL, { keyPrefix: fastify.config.NODE_ENV === 'test' - ? /* istanbul ignore next */ v4() + ? /* istanbul ignore next */ `${v4()}:` : /* istanbul ignore next */ undefined, lazyConnect: true, }).on( @@ -70,22 +70,24 @@ export default fastifyPlugin( redis.invalidateCaches = async (...keys) => { await Promise.all( keys.map(async (key) => { - // If the key is a pattern, delete all keys matching the pattern - if (key.includes('*')) { - const stream = redis.scanStream({ - match: key, - }); - - stream.on('data', async (keys) => { - await Promise.all( - keys.map(async (key: RedisKey) => { - await redis.del(key); - }), - ); - }); - - return; - } + // If the key is a pattern, delete all keys matching the pattern + if (key.includes('*')) { + // Get all keys matching pattern + keys = await redis.keys( + `${redis.options.keyPrefix ? redis.options.keyPrefix : ''}${key}`, + ); + + await Promise.all( + keys.map(async (key) => { + if (redis.options.keyPrefix) { + await redis.del(key.replace(redis.options.keyPrefix, '')); + return; + } + + await redis.del(key); + }), + ); + } await redis.del(key); }), From 2a42f7cc77d3bb33cd404e8b4d464a1a01263209 Mon Sep 17 00:00:00 2001 From: Kristian Binau Date: Sun, 22 Oct 2023 22:24:39 +0200 Subject: [PATCH 7/9] #42 - Remove unnessesary imports --- src/plugins/redis.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/redis.ts b/src/plugins/redis.ts index 4118d0c..d670256 100644 --- a/src/plugins/redis.ts +++ b/src/plugins/redis.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from 'fastify'; import fastifyPlugin from 'fastify-plugin'; -import Redis, { Callback, RedisKey } from 'ioredis'; +import Redis from 'ioredis'; import { v4 } from 'uuid'; declare module 'fastify' { From dff6f72cf6bbfc476d9a90cafc4339e74c4eff1b Mon Sep 17 00:00:00 2001 From: Kristian Binau Date: Sun, 22 Oct 2023 23:02:18 +0200 Subject: [PATCH 8/9] #42 - Code coverage --- src/modules/item/blob/blob.service.ts | 6 ++++-- src/modules/item/docs/docs.service.ts | 1 + src/modules/item/folder/folder.service.ts | 1 + src/modules/item/shortcut/shortcut.service.ts | 1 + src/plugins/redis.ts | 5 ++++- 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/modules/item/blob/blob.service.ts b/src/modules/item/blob/blob.service.ts index cdf3738..148d3ed 100644 --- a/src/modules/item/blob/blob.service.ts +++ b/src/modules/item/blob/blob.service.ts @@ -119,6 +119,7 @@ export default class BlobService { try { blob = await this.getByItemId(itemId); } catch (e) { + /* istanbul ignore next */ return; } @@ -129,7 +130,8 @@ export default class BlobService { }, }), ItemService.invalidateCachesForItem(blob), - async () => { + (async () => { + /* istanbul ignore next */ if (!blob.blobUrl) { return; } @@ -139,7 +141,7 @@ export default class BlobService { } catch (e) { // Do nothing } - }, + })(), ]); } diff --git a/src/modules/item/docs/docs.service.ts b/src/modules/item/docs/docs.service.ts index cce56f1..9f57f8c 100644 --- a/src/modules/item/docs/docs.service.ts +++ b/src/modules/item/docs/docs.service.ts @@ -89,6 +89,7 @@ export default class DocsService { try { docs = await this.getByItemId(itemId); } catch (e) { + /* istanbul ignore next */ return; } diff --git a/src/modules/item/folder/folder.service.ts b/src/modules/item/folder/folder.service.ts index 97f9560..ac2d368 100644 --- a/src/modules/item/folder/folder.service.ts +++ b/src/modules/item/folder/folder.service.ts @@ -89,6 +89,7 @@ export default class FolderService { try { folder = await this.getByItemId(itemId); } catch (e) { + /* istanbul ignore next */ return; } diff --git a/src/modules/item/shortcut/shortcut.service.ts b/src/modules/item/shortcut/shortcut.service.ts index 8491bc8..1f2cf6e 100644 --- a/src/modules/item/shortcut/shortcut.service.ts +++ b/src/modules/item/shortcut/shortcut.service.ts @@ -92,6 +92,7 @@ export default class ShortcutService { try { shortcut = await this.getByItemId(itemId); } catch (e) { + /* istanbul ignore next */ return; } diff --git a/src/plugins/redis.ts b/src/plugins/redis.ts index d670256..1d7a259 100644 --- a/src/plugins/redis.ts +++ b/src/plugins/redis.ts @@ -74,7 +74,9 @@ export default fastifyPlugin( if (key.includes('*')) { // Get all keys matching pattern keys = await redis.keys( - `${redis.options.keyPrefix ? redis.options.keyPrefix : ''}${key}`, + `${ + redis.options.keyPrefix ? redis.options.keyPrefix : /* istanbul ignore next */ '' + }${key}`, ); await Promise.all( @@ -84,6 +86,7 @@ export default fastifyPlugin( return; } + /* istanbul ignore next */ await redis.del(key); }), ); From 8bcf458c09248cd3c173dbaae40246470d15c936 Mon Sep 17 00:00:00 2001 From: Kristian Binau Date: Mon, 23 Oct 2023 09:52:15 +0200 Subject: [PATCH 9/9] #42 - Change TTL --- src/modules/item/item.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/item/item.controller.ts b/src/modules/item/item.controller.ts index 4265397..acfe840 100644 --- a/src/modules/item/item.controller.ts +++ b/src/modules/item/item.controller.ts @@ -11,7 +11,7 @@ import AccessService from './sharing/access.service'; import { UnauthorizedError, errorReply } from '../../utils/error'; export const CACHE_ITEMS = 'items'; -const CACHE_TTL = 10000; +const CACHE_TTL = 86400; export default class ItemController { private itemService: ItemService;