Enforce maxViews atomically when reserving a view slot.
Use a conditional UPDATE with parenthesized OR so increment only applies to the target row and fails when the view cap is reached.
This commit is contained in:
@@ -54,17 +54,24 @@ describe('UploadService', () => {
|
|||||||
create: jest.Mock;
|
create: jest.Mock;
|
||||||
save: jest.Mock;
|
save: jest.Mock;
|
||||||
findOne: jest.Mock;
|
findOne: jest.Mock;
|
||||||
increment: jest.Mock;
|
createQueryBuilder: jest.Mock;
|
||||||
remove: jest.Mock;
|
remove: jest.Mock;
|
||||||
};
|
};
|
||||||
let transaction: jest.Mock;
|
let transaction: jest.Mock;
|
||||||
|
let reserveViewExecute: jest.Mock;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
reserveViewExecute = jest.fn().mockResolvedValue({ affected: 1 });
|
||||||
|
const andWhere = jest.fn().mockReturnValue({ execute: reserveViewExecute });
|
||||||
|
const where = jest.fn().mockReturnValue({ andWhere });
|
||||||
|
const set = jest.fn().mockReturnValue({ where });
|
||||||
|
const update = jest.fn().mockReturnValue({ set });
|
||||||
|
|
||||||
uploadRepository = {
|
uploadRepository = {
|
||||||
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: [] }),
|
createQueryBuilder: jest.fn().mockReturnValue({ update }),
|
||||||
remove: jest.fn().mockResolvedValue(undefined)
|
remove: jest.fn().mockResolvedValue(undefined)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -232,7 +239,7 @@ describe('UploadService', () => {
|
|||||||
relations: ['zk']
|
relations: ['zk']
|
||||||
});
|
});
|
||||||
expect(storageService.read).toHaveBeenCalledWith(fixedId);
|
expect(storageService.read).toHaveBeenCalledWith(fixedId);
|
||||||
expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1);
|
expect(reserveViewExecute).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes wrap fields when password protected', async () => {
|
it('includes wrap fields when password protected', async () => {
|
||||||
@@ -262,7 +269,7 @@ describe('UploadService', () => {
|
|||||||
wrapIv: wrapIv.toString('base64'),
|
wrapIv: wrapIv.toString('base64'),
|
||||||
salt: salt.toString('base64')
|
salt: salt.toString('base64')
|
||||||
});
|
});
|
||||||
expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1);
|
expect(reserveViewExecute).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException when upload is missing or not zk', async () => {
|
it('throws NotFoundException when upload is missing or not zk', async () => {
|
||||||
@@ -270,7 +277,7 @@ describe('UploadService', () => {
|
|||||||
|
|
||||||
await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException);
|
await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException);
|
||||||
expect(storageService.read).not.toHaveBeenCalled();
|
expect(storageService.read).not.toHaveBeenCalled();
|
||||||
expect(uploadRepository.increment).not.toHaveBeenCalled();
|
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException when ciphertext file is missing', async () => {
|
it('throws NotFoundException when ciphertext file is missing', async () => {
|
||||||
@@ -282,7 +289,7 @@ describe('UploadService', () => {
|
|||||||
storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||||
|
|
||||||
await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException);
|
await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException);
|
||||||
expect(uploadRepository.increment).not.toHaveBeenCalled();
|
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException when password protected but wrap data is incomplete', async () => {
|
it('throws NotFoundException when password protected but wrap data is incomplete', async () => {
|
||||||
@@ -299,7 +306,7 @@ describe('UploadService', () => {
|
|||||||
storageService.read.mockResolvedValue(Buffer.alloc(8));
|
storageService.read.mockResolvedValue(Buffer.alloc(8));
|
||||||
|
|
||||||
await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException);
|
await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException);
|
||||||
expect(uploadRepository.increment).not.toHaveBeenCalled();
|
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException when expiresAt is in the past', async () => {
|
it('throws NotFoundException when expiresAt is in the past', async () => {
|
||||||
@@ -317,7 +324,7 @@ describe('UploadService', () => {
|
|||||||
|
|
||||||
await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException);
|
await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException);
|
||||||
expect(storageService.read).not.toHaveBeenCalled();
|
expect(storageService.read).not.toHaveBeenCalled();
|
||||||
expect(uploadRepository.increment).not.toHaveBeenCalled();
|
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException when maxViews is exhausted', async () => {
|
it('throws NotFoundException when maxViews is exhausted', async () => {
|
||||||
@@ -336,7 +343,7 @@ describe('UploadService', () => {
|
|||||||
|
|
||||||
await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException);
|
await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException);
|
||||||
expect(storageService.read).not.toHaveBeenCalled();
|
expect(storageService.read).not.toHaveBeenCalled();
|
||||||
expect(uploadRepository.increment).not.toHaveBeenCalled();
|
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not purge on GET after the last allowed view', async () => {
|
it('does not purge on GET after the last allowed view', async () => {
|
||||||
@@ -365,7 +372,7 @@ describe('UploadService', () => {
|
|||||||
passwordProtected: false
|
passwordProtected: false
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1);
|
expect(reserveViewExecute).toHaveBeenCalled();
|
||||||
expect(storageService.unlink).not.toHaveBeenCalled();
|
expect(storageService.unlink).not.toHaveBeenCalled();
|
||||||
expect(uploadRepository.remove).not.toHaveBeenCalled();
|
expect(uploadRepository.remove).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -395,7 +402,7 @@ describe('UploadService', () => {
|
|||||||
where: { id: fixedId, mode: UploadMode.Hotlink },
|
where: { id: fixedId, mode: UploadMode.Hotlink },
|
||||||
relations: ['hotlink']
|
relations: ['hotlink']
|
||||||
});
|
});
|
||||||
expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1);
|
expect(reserveViewExecute).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException when upload is missing or not hotlink', async () => {
|
it('throws NotFoundException when upload is missing or not hotlink', async () => {
|
||||||
@@ -403,7 +410,7 @@ describe('UploadService', () => {
|
|||||||
|
|
||||||
await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException);
|
await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException);
|
||||||
expect(storageService.read).not.toHaveBeenCalled();
|
expect(storageService.read).not.toHaveBeenCalled();
|
||||||
expect(uploadRepository.increment).not.toHaveBeenCalled();
|
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException when secret is wrong but well-formed', async () => {
|
it('throws NotFoundException when secret is wrong but well-formed', async () => {
|
||||||
@@ -417,7 +424,7 @@ describe('UploadService', () => {
|
|||||||
const wrongSecret = Buffer.alloc(32, 0x99).toString('base64url');
|
const wrongSecret = Buffer.alloc(32, 0x99).toString('base64url');
|
||||||
|
|
||||||
await expect(service.getHotlinkImage(fixedId, wrongSecret)).rejects.toThrow(NotFoundException);
|
await expect(service.getHotlinkImage(fixedId, wrongSecret)).rejects.toThrow(NotFoundException);
|
||||||
expect(uploadRepository.increment).not.toHaveBeenCalled();
|
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException when ciphertext file is missing', async () => {
|
it('throws NotFoundException when ciphertext file is missing', async () => {
|
||||||
@@ -429,7 +436,7 @@ describe('UploadService', () => {
|
|||||||
storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||||
|
|
||||||
await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException);
|
await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException);
|
||||||
expect(uploadRepository.increment).not.toHaveBeenCalled();
|
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException when expiresAt is in the past', async () => {
|
it('throws NotFoundException when expiresAt is in the past', async () => {
|
||||||
@@ -442,7 +449,7 @@ describe('UploadService', () => {
|
|||||||
|
|
||||||
await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException);
|
await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException);
|
||||||
expect(storageService.read).not.toHaveBeenCalled();
|
expect(storageService.read).not.toHaveBeenCalled();
|
||||||
expect(uploadRepository.increment).not.toHaveBeenCalled();
|
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException when maxViews is exhausted', async () => {
|
it('throws NotFoundException when maxViews is exhausted', async () => {
|
||||||
@@ -456,7 +463,7 @@ describe('UploadService', () => {
|
|||||||
|
|
||||||
await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException);
|
await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException);
|
||||||
expect(storageService.read).not.toHaveBeenCalled();
|
expect(storageService.read).not.toHaveBeenCalled();
|
||||||
expect(uploadRepository.increment).not.toHaveBeenCalled();
|
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not purge on GET after the last allowed view', async () => {
|
it('does not purge on GET after the last allowed view', async () => {
|
||||||
@@ -477,7 +484,7 @@ describe('UploadService', () => {
|
|||||||
mimeType: 'image/png'
|
mimeType: 'image/png'
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1);
|
expect(reserveViewExecute).toHaveBeenCalled();
|
||||||
expect(storageService.unlink).not.toHaveBeenCalled();
|
expect(storageService.unlink).not.toHaveBeenCalled();
|
||||||
expect(uploadRepository.remove).not.toHaveBeenCalled();
|
expect(uploadRepository.remove).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -497,7 +504,7 @@ describe('UploadService', () => {
|
|||||||
mimeType: 'image/png'
|
mimeType: 'image/png'
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(uploadRepository.increment).toHaveBeenCalled();
|
expect(reserveViewExecute).toHaveBeenCalled();
|
||||||
expect(storageService.unlink).not.toHaveBeenCalled();
|
expect(storageService.unlink).not.toHaveBeenCalled();
|
||||||
expect(uploadRepository.remove).not.toHaveBeenCalled();
|
expect(uploadRepository.remove).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export class UploadService {
|
|||||||
result.salt = toBase64(upload.zk.salt);
|
result.salt = toBase64(upload.zk.salt);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.uploadRepository.increment({ id }, 'viewCount', 1);
|
await this.reserveViewSlot(id);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -210,7 +210,7 @@ export class UploadService {
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.uploadRepository.increment({ id }, 'viewCount', 1);
|
await this.reserveViewSlot(id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
body,
|
body,
|
||||||
@@ -218,6 +218,20 @@ export class UploadService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async reserveViewSlot(id: string): Promise<void> {
|
||||||
|
const { affected } = await this.uploadRepository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update('uploads')
|
||||||
|
.set({ viewCount: () => '"viewCount" + 1' })
|
||||||
|
.where('id = :id', { id })
|
||||||
|
.andWhere('("maxViews" IS NULL OR "viewCount" < "maxViews")')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (!affected) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async throwIfUnencryptedImage(ciphertext: Buffer): Promise<void> {
|
private async throwIfUnencryptedImage(ciphertext: Buffer): Promise<void> {
|
||||||
const detected = await fileTypeFromBuffer(ciphertext);
|
const detected = await fileTypeFromBuffer(ciphertext);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user