diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts index 7060632..f796dea 100644 --- a/backend/src/modules/upload/services/UploadService.spec.ts +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -320,6 +320,56 @@ describe('UploadService', () => { expect(storageService.read).not.toHaveBeenCalled(); expect(uploadRepository.increment).not.toHaveBeenCalled(); }); + + it('throws NotFoundException when maxViews is exhausted', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv: Buffer.alloc(12, 0x11), + maxViews: 1, + viewCount: 1, + zk: { + passwordProtected: false, + wrappedDek: null, + wrapIv: null, + salt: null + } + }); + + await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); + expect(storageService.read).not.toHaveBeenCalled(); + expect(uploadRepository.increment).not.toHaveBeenCalled(); + }); + + it('purges upload after the last allowed view', async () => { + const iv = Buffer.alloc(12, 0x11); + const ciphertext = Buffer.alloc(64, 0xcd); + const upload = { + id: fixedId, + storagePath: fixedId, + iv, + maxViews: 1, + viewCount: 0, + zk: { + passwordProtected: false, + wrappedDek: null, + wrapIv: null, + salt: null + } + }; + + uploadRepository.findOne.mockResolvedValue(upload); + storageService.read.mockResolvedValue(ciphertext); + + await expect(service.getZkUploadCiphertext(fixedId)).resolves.toEqual({ + iv: iv.toString('base64'), + ciphertext: ciphertext.toString('base64'), + passwordProtected: false + }); + + expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); + expect(storageService.unlink).toHaveBeenCalledWith(fixedId); + expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + }); }); describe('getHotlinkImage', () => { @@ -395,6 +445,63 @@ describe('UploadService', () => { expect(storageService.read).not.toHaveBeenCalled(); expect(uploadRepository.increment).not.toHaveBeenCalled(); }); + + it('throws NotFoundException when maxViews is exhausted', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv, + maxViews: 2, + viewCount: 2, + hotlink: { mimeType: 'image/png' } + }); + + await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); + expect(storageService.read).not.toHaveBeenCalled(); + expect(uploadRepository.increment).not.toHaveBeenCalled(); + }); + + it('purges upload after the last allowed view', async () => { + const upload = { + id: fixedId, + storagePath: fixedId, + iv, + maxViews: 1, + viewCount: 0, + hotlink: { mimeType: 'image/png' } + }; + + uploadRepository.findOne.mockResolvedValue(upload); + storageService.read.mockResolvedValue(ciphertext); + + await expect(service.getHotlinkImage(fixedId, secret)).resolves.toEqual({ + body: plaintext, + mimeType: 'image/png' + }); + + expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); + expect(storageService.unlink).toHaveBeenCalledWith(fixedId); + expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + }); + + it('does not purge when views remain below maxViews', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv, + maxViews: 3, + viewCount: 0, + hotlink: { mimeType: 'image/png' } + }); + storageService.read.mockResolvedValue(ciphertext); + + await expect(service.getHotlinkImage(fixedId, secret)).resolves.toEqual({ + body: plaintext, + mimeType: 'image/png' + }); + + expect(uploadRepository.increment).toHaveBeenCalled(); + expect(storageService.unlink).not.toHaveBeenCalled(); + expect(uploadRepository.remove).not.toHaveBeenCalled(); + }); }); describe('deleteUpload', () => { diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index 30c89a7..2eae57d 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -132,6 +132,10 @@ export class UploadService { throw new NotFoundException(); } + if (typeof upload.maxViews === 'number' && upload.viewCount >= upload.maxViews) { + throw new NotFoundException(); + } + let ciphertextBuffer: Buffer; try { @@ -162,6 +166,12 @@ export class UploadService { await this.uploadRepository.increment({ id }, 'viewCount', 1); + const nextViewCount = upload.viewCount + 1; + + if (typeof upload.maxViews === 'number' && nextViewCount >= upload.maxViews) { + await this.purgeUpload(upload, this.uploadRepository); + } + return result; } @@ -179,6 +189,10 @@ export class UploadService { throw new NotFoundException(); } + if (typeof upload.maxViews === 'number' && upload.viewCount >= upload.maxViews) { + throw new NotFoundException(); + } + let ciphertextBuffer: Buffer; try { @@ -203,6 +217,12 @@ export class UploadService { await this.uploadRepository.increment({ id }, 'viewCount', 1); + const nextViewCount = upload.viewCount + 1; + + if (typeof upload.maxViews === 'number' && nextViewCount >= upload.maxViews) { + await this.purgeUpload(upload, this.uploadRepository); + } + return { body, mimeType: upload.hotlink.mimeType @@ -221,18 +241,22 @@ export class UploadService { throw new NotFoundException(); } - try { - await this.storageService.unlink(upload.storagePath); - } catch (error: unknown) { - if (!isErrorEnoent(error)) { - throw error; - } - } - - await uploadRepository.remove(upload); + await this.purgeUpload(upload, uploadRepository); }); } + private async purgeUpload(upload: Upload, uploadRepository: Repository): Promise { + try { + await this.storageService.unlink(upload.storagePath); + } catch (error: unknown) { + if (!isErrorEnoent(error)) { + throw error; + } + } + + await uploadRepository.remove(upload); + } + private hashDeleteToken(token: string): string { return createHash('sha256').update(token, 'utf8').digest('hex'); }