Add DELETE /api/uploads/:id with delete_token and unlink ciphertext.
Return 204 when the token hash matches; ignore missing files on unlink so the row is still removed.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class DeleteUploadQueryDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
delete_token: string;
|
||||
}
|
||||
@@ -50,8 +50,14 @@ const encryptHotlinkCiphertext = (plaintext: Buffer, key: Buffer, iv: Buffer): B
|
||||
describe('UploadService', () => {
|
||||
let service: UploadService;
|
||||
let dataSource: jest.Mocked<Pick<DataSource, 'transaction'>>;
|
||||
let storageService: jest.Mocked<Pick<StorageService, 'write' | 'read'>>;
|
||||
let uploadRepository: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock; increment: jest.Mock };
|
||||
let storageService: jest.Mocked<Pick<StorageService, 'write' | 'read' | 'unlink'>>;
|
||||
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<unknown>) => {
|
||||
@@ -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'));
|
||||
|
||||
@@ -200,6 +200,30 @@ export class UploadService {
|
||||
};
|
||||
}
|
||||
|
||||
async deleteUpload(id: string, deleteToken: string): Promise<void> {
|
||||
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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user