Slave/lifecycle #11

Merged
nobswebdev merged 9 commits from slave/lifecycle into master 2026-10-27 21:12:04 +00:00
2 changed files with 140 additions and 9 deletions
Showing only changes of commit c147a010b5 - Show all commits
@@ -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');
} }