diff --git a/backend/src/modules/upload/controllers/UploadController.ts b/backend/src/modules/upload/controllers/UploadController.ts index b6fae3c..b4124bf 100644 --- a/backend/src/modules/upload/controllers/UploadController.ts +++ b/backend/src/modules/upload/controllers/UploadController.ts @@ -1,8 +1,9 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { throttleProfiles } from '../../../config/throttleProfiles'; import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto'; import { CreateZkUploadDto } from '../dto/CreateZkUploadDto'; +import { DeleteUploadQueryDto } from '../dto/DeleteUploadQueryDto'; import { UploadService } from '../services/UploadService'; @Controller('uploads') @@ -25,4 +26,10 @@ export class UploadController { getZkCiphertext(@Param('id', ParseUUIDPipe) id: string) { return this.uploadService.getZkUploadCiphertext(id); } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + deleteUpload(@Param('id', ParseUUIDPipe) id: string, @Query() { delete_token }: DeleteUploadQueryDto) { + return this.uploadService.deleteUpload(id, delete_token); + } } diff --git a/backend/src/modules/upload/dto/DeleteUploadQueryDto.ts b/backend/src/modules/upload/dto/DeleteUploadQueryDto.ts new file mode 100644 index 0000000..003d89c --- /dev/null +++ b/backend/src/modules/upload/dto/DeleteUploadQueryDto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class DeleteUploadQueryDto { + @IsNotEmpty() + @IsString() + delete_token: string; +} diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts index 04bc0c9..3ba9041 100644 --- a/backend/src/modules/upload/services/UploadService.spec.ts +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -50,8 +50,14 @@ const encryptHotlinkCiphertext = (plaintext: Buffer, key: Buffer, iv: Buffer): B describe('UploadService', () => { let service: UploadService; let dataSource: jest.Mocked>; - let storageService: jest.Mocked>; - let uploadRepository: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock; increment: jest.Mock }; + let storageService: jest.Mocked>; + let uploadRepository: { + create: jest.Mock; + save: jest.Mock; + findOne: jest.Mock; + increment: jest.Mock; + remove: jest.Mock; + }; let transaction: jest.Mock; beforeEach(() => { @@ -59,7 +65,8 @@ describe('UploadService', () => { create: jest.fn((entityLike: unknown) => entityLike), save: jest.fn(async (entity: unknown) => entity), findOne: jest.fn(), - increment: jest.fn().mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }) + increment: jest.fn().mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }), + remove: jest.fn().mockResolvedValue(undefined) }; transaction = jest.fn(async (runInTransaction: (manager: EntityManager) => Promise) => { @@ -73,7 +80,8 @@ describe('UploadService', () => { dataSource = { transaction }; storageService = { write: jest.fn().mockResolvedValue(undefined), - read: jest.fn() + read: jest.fn(), + unlink: jest.fn().mockResolvedValue(undefined) }; mockFileTypeFromBuffer.mockResolvedValue(undefined); @@ -358,6 +366,68 @@ describe('UploadService', () => { }); }); + describe('deleteUpload', () => { + const deleteToken = fixedDeleteTokenBytes.toString('base64url'); + + it('unlinks ciphertext and removes upload when delete token matches', async () => { + const upload = { + storagePath: fixedId, + deleteTokenHash: hashDeleteToken(deleteToken) + }; + + uploadRepository.findOne.mockResolvedValue(upload); + + await expect(service.deleteUpload(fixedId, deleteToken)).resolves.toBeUndefined(); + + expect(uploadRepository.findOne).toHaveBeenCalledWith({ + where: { id: fixedId, deleteTokenHash: hashDeleteToken(deleteToken) } + }); + expect(storageService.unlink).toHaveBeenCalledWith(fixedId); + expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + }); + + it('throws NotFoundException when upload is missing', async () => { + uploadRepository.findOne.mockResolvedValue(null); + + await expect(service.deleteUpload(fixedId, deleteToken)).rejects.toThrow(NotFoundException); + expect(storageService.unlink).not.toHaveBeenCalled(); + expect(uploadRepository.remove).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when delete token is wrong', async () => { + uploadRepository.findOne.mockResolvedValue(null); + + await expect(service.deleteUpload(fixedId, 'wrong-token')).rejects.toThrow(NotFoundException); + expect(storageService.unlink).not.toHaveBeenCalled(); + expect(uploadRepository.remove).not.toHaveBeenCalled(); + }); + + it('still removes upload when ciphertext file is already missing', async () => { + const upload = { + storagePath: fixedId, + deleteTokenHash: hashDeleteToken(deleteToken) + }; + + uploadRepository.findOne.mockResolvedValue(upload); + storageService.unlink.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + await expect(service.deleteUpload(fixedId, deleteToken)).resolves.toBeUndefined(); + + expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + }); + + it('propagates non-ENOENT storage unlink errors', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + deleteTokenHash: hashDeleteToken(deleteToken) + }); + storageService.unlink.mockRejectedValue(new Error('permission denied')); + + await expect(service.deleteUpload(fixedId, deleteToken)).rejects.toThrow('permission denied'); + expect(uploadRepository.remove).not.toHaveBeenCalled(); + }); + }); + describe('transaction rollback on storage failure', () => { it('propagates storage write errors from the transaction', async () => { storageService.write.mockRejectedValue(new Error('disk full')); diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index 489220e..27c5b8a 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -200,6 +200,30 @@ export class UploadService { }; } + async deleteUpload(id: string, deleteToken: string): Promise { + await this.dataSource.transaction(async manager => { + const uploadRepository = manager.getRepository(Upload); + + const upload = await uploadRepository.findOne({ + where: { id, deleteTokenHash: this.hashDeleteToken(deleteToken) } + }); + + if (!upload) { + throw new NotFoundException(); + } + + 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'); }