Slave/lifecycle #11
@@ -320,6 +320,56 @@ describe('UploadService', () => {
|
|||||||
expect(storageService.read).not.toHaveBeenCalled();
|
expect(storageService.read).not.toHaveBeenCalled();
|
||||||
expect(uploadRepository.increment).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', () => {
|
describe('getHotlinkImage', () => {
|
||||||
@@ -395,6 +445,63 @@ describe('UploadService', () => {
|
|||||||
expect(storageService.read).not.toHaveBeenCalled();
|
expect(storageService.read).not.toHaveBeenCalled();
|
||||||
expect(uploadRepository.increment).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', () => {
|
describe('deleteUpload', () => {
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ export class UploadService {
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof upload.maxViews === 'number' && upload.viewCount >= upload.maxViews) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
let ciphertextBuffer: Buffer;
|
let ciphertextBuffer: Buffer;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -162,6 +166,12 @@ export class UploadService {
|
|||||||
|
|
||||||
await this.uploadRepository.increment({ id }, 'viewCount', 1);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +189,10 @@ export class UploadService {
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof upload.maxViews === 'number' && upload.viewCount >= upload.maxViews) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
let ciphertextBuffer: Buffer;
|
let ciphertextBuffer: Buffer;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -203,6 +217,12 @@ export class UploadService {
|
|||||||
|
|
||||||
await this.uploadRepository.increment({ id }, 'viewCount', 1);
|
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 {
|
return {
|
||||||
body,
|
body,
|
||||||
mimeType: upload.hotlink.mimeType
|
mimeType: upload.hotlink.mimeType
|
||||||
@@ -221,18 +241,22 @@ export class UploadService {
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await this.purgeUpload(upload, uploadRepository);
|
||||||
await this.storageService.unlink(upload.storagePath);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!isErrorEnoent(error)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await uploadRepository.remove(upload);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async purgeUpload(upload: Upload, uploadRepository: Repository<Upload>): Promise<void> {
|
||||||
|
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