diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts index e638663..299d8bf 100644 --- a/backend/src/modules/upload/services/UploadService.spec.ts +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -54,17 +54,24 @@ describe('UploadService', () => { create: jest.Mock; save: jest.Mock; findOne: jest.Mock; - increment: jest.Mock; + createQueryBuilder: jest.Mock; remove: jest.Mock; }; let transaction: jest.Mock; + let reserveViewExecute: jest.Mock; beforeEach(() => { + reserveViewExecute = jest.fn().mockResolvedValue({ affected: 1 }); + const andWhere = jest.fn().mockReturnValue({ execute: reserveViewExecute }); + const where = jest.fn().mockReturnValue({ andWhere }); + const set = jest.fn().mockReturnValue({ where }); + const update = jest.fn().mockReturnValue({ set }); + uploadRepository = { create: jest.fn((entityLike: unknown) => entityLike), save: jest.fn(async (entity: unknown) => entity), findOne: jest.fn(), - increment: jest.fn().mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }), + createQueryBuilder: jest.fn().mockReturnValue({ update }), remove: jest.fn().mockResolvedValue(undefined) }; @@ -232,7 +239,7 @@ describe('UploadService', () => { relations: ['zk'] }); expect(storageService.read).toHaveBeenCalledWith(fixedId); - expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); + expect(reserveViewExecute).toHaveBeenCalled(); }); it('includes wrap fields when password protected', async () => { @@ -262,7 +269,7 @@ describe('UploadService', () => { wrapIv: wrapIv.toString('base64'), salt: salt.toString('base64') }); - expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); + expect(reserveViewExecute).toHaveBeenCalled(); }); it('throws NotFoundException when upload is missing or not zk', async () => { @@ -270,7 +277,7 @@ describe('UploadService', () => { await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); expect(storageService.read).not.toHaveBeenCalled(); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when ciphertext file is missing', async () => { @@ -282,7 +289,7 @@ describe('UploadService', () => { storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when password protected but wrap data is incomplete', async () => { @@ -299,7 +306,7 @@ describe('UploadService', () => { storageService.read.mockResolvedValue(Buffer.alloc(8)); await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when expiresAt is in the past', async () => { @@ -317,7 +324,7 @@ describe('UploadService', () => { await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); expect(storageService.read).not.toHaveBeenCalled(); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when maxViews is exhausted', async () => { @@ -336,7 +343,7 @@ describe('UploadService', () => { await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); expect(storageService.read).not.toHaveBeenCalled(); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('does not purge on GET after the last allowed view', async () => { @@ -365,7 +372,7 @@ describe('UploadService', () => { passwordProtected: false }); - expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); + expect(reserveViewExecute).toHaveBeenCalled(); expect(storageService.unlink).not.toHaveBeenCalled(); expect(uploadRepository.remove).not.toHaveBeenCalled(); }); @@ -395,7 +402,7 @@ describe('UploadService', () => { where: { id: fixedId, mode: UploadMode.Hotlink }, relations: ['hotlink'] }); - expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); + expect(reserveViewExecute).toHaveBeenCalled(); }); it('throws NotFoundException when upload is missing or not hotlink', async () => { @@ -403,7 +410,7 @@ describe('UploadService', () => { await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); expect(storageService.read).not.toHaveBeenCalled(); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when secret is wrong but well-formed', async () => { @@ -417,7 +424,7 @@ describe('UploadService', () => { const wrongSecret = Buffer.alloc(32, 0x99).toString('base64url'); await expect(service.getHotlinkImage(fixedId, wrongSecret)).rejects.toThrow(NotFoundException); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when ciphertext file is missing', async () => { @@ -429,7 +436,7 @@ describe('UploadService', () => { storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when expiresAt is in the past', async () => { @@ -442,7 +449,7 @@ describe('UploadService', () => { await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); expect(storageService.read).not.toHaveBeenCalled(); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when maxViews is exhausted', async () => { @@ -456,7 +463,7 @@ describe('UploadService', () => { await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); expect(storageService.read).not.toHaveBeenCalled(); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('does not purge on GET after the last allowed view', async () => { @@ -477,7 +484,7 @@ describe('UploadService', () => { mimeType: 'image/png' }); - expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); + expect(reserveViewExecute).toHaveBeenCalled(); expect(storageService.unlink).not.toHaveBeenCalled(); expect(uploadRepository.remove).not.toHaveBeenCalled(); }); @@ -497,7 +504,7 @@ describe('UploadService', () => { mimeType: 'image/png' }); - expect(uploadRepository.increment).toHaveBeenCalled(); + expect(reserveViewExecute).toHaveBeenCalled(); expect(storageService.unlink).not.toHaveBeenCalled(); expect(uploadRepository.remove).not.toHaveBeenCalled(); }); diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index e9f44ca..fa2a9ef 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -165,7 +165,7 @@ export class UploadService { result.salt = toBase64(upload.zk.salt); } - await this.uploadRepository.increment({ id }, 'viewCount', 1); + await this.reserveViewSlot(id); return result; } @@ -210,7 +210,7 @@ export class UploadService { throw new NotFoundException(); } - await this.uploadRepository.increment({ id }, 'viewCount', 1); + await this.reserveViewSlot(id); return { body, @@ -218,6 +218,20 @@ export class UploadService { }; } + private async reserveViewSlot(id: string): Promise { + const { affected } = await this.uploadRepository + .createQueryBuilder() + .update('uploads') + .set({ viewCount: () => '"viewCount" + 1' }) + .where('id = :id', { id }) + .andWhere('("maxViews" IS NULL OR "viewCount" < "maxViews")') + .execute(); + + if (!affected) { + throw new NotFoundException(); + } + } + private async throwIfUnencryptedImage(ciphertext: Buffer): Promise { const detected = await fileTypeFromBuffer(ciphertext);