Slave/lifecycle #11
@@ -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 { Throttle } from '@nestjs/throttler';
|
||||||
import { throttleProfiles } from '../../../config/throttleProfiles';
|
import { throttleProfiles } from '../../../config/throttleProfiles';
|
||||||
import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto';
|
import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto';
|
||||||
import { CreateZkUploadDto } from '../dto/CreateZkUploadDto';
|
import { CreateZkUploadDto } from '../dto/CreateZkUploadDto';
|
||||||
|
import { DeleteUploadQueryDto } from '../dto/DeleteUploadQueryDto';
|
||||||
import { UploadService } from '../services/UploadService';
|
import { UploadService } from '../services/UploadService';
|
||||||
|
|
||||||
@Controller('uploads')
|
@Controller('uploads')
|
||||||
@@ -25,4 +26,10 @@ export class UploadController {
|
|||||||
getZkCiphertext(@Param('id', ParseUUIDPipe) id: string) {
|
getZkCiphertext(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.uploadService.getZkUploadCiphertext(id);
|
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', () => {
|
describe('UploadService', () => {
|
||||||
let service: UploadService;
|
let service: UploadService;
|
||||||
let dataSource: jest.Mocked<Pick<DataSource, 'transaction'>>;
|
let dataSource: jest.Mocked<Pick<DataSource, 'transaction'>>;
|
||||||
let storageService: jest.Mocked<Pick<StorageService, 'write' | 'read'>>;
|
let storageService: jest.Mocked<Pick<StorageService, 'write' | 'read' | 'unlink'>>;
|
||||||
let uploadRepository: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock; increment: jest.Mock };
|
let uploadRepository: {
|
||||||
|
create: jest.Mock;
|
||||||
|
save: jest.Mock;
|
||||||
|
findOne: jest.Mock;
|
||||||
|
increment: jest.Mock;
|
||||||
|
remove: jest.Mock;
|
||||||
|
};
|
||||||
let transaction: jest.Mock;
|
let transaction: jest.Mock;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -59,7 +65,8 @@ describe('UploadService', () => {
|
|||||||
create: jest.fn((entityLike: unknown) => entityLike),
|
create: jest.fn((entityLike: unknown) => entityLike),
|
||||||
save: jest.fn(async (entity: unknown) => entity),
|
save: jest.fn(async (entity: unknown) => entity),
|
||||||
findOne: jest.fn(),
|
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>) => {
|
transaction = jest.fn(async (runInTransaction: (manager: EntityManager) => Promise<unknown>) => {
|
||||||
@@ -73,7 +80,8 @@ describe('UploadService', () => {
|
|||||||
dataSource = { transaction };
|
dataSource = { transaction };
|
||||||
storageService = {
|
storageService = {
|
||||||
write: jest.fn().mockResolvedValue(undefined),
|
write: jest.fn().mockResolvedValue(undefined),
|
||||||
read: jest.fn()
|
read: jest.fn(),
|
||||||
|
unlink: jest.fn().mockResolvedValue(undefined)
|
||||||
};
|
};
|
||||||
|
|
||||||
mockFileTypeFromBuffer.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', () => {
|
describe('transaction rollback on storage failure', () => {
|
||||||
it('propagates storage write errors from the transaction', async () => {
|
it('propagates storage write errors from the transaction', async () => {
|
||||||
storageService.write.mockRejectedValue(new Error('disk full'));
|
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 {
|
private hashDeleteToken(token: string): string {
|
||||||
return createHash('sha256').update(token, 'utf8').digest('hex');
|
return createHash('sha256').update(token, 'utf8').digest('hex');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user