Slave/lifecycle #11
@@ -0,0 +1,13 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class DropUploadLastViewedAt1791230839247 implements MigrationInterface {
|
||||
name = 'DropUploadLastViewedAt1791230839247';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "uploads" DROP COLUMN "lastViewedAt"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "uploads" ADD "lastViewedAt" TIMESTAMP WITH TIME ZONE`);
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,12 @@ import { UploadController } from './controllers/UploadController';
|
||||
import { Upload } from './entities/Upload';
|
||||
import { UploadHotlink } from './entities/UploadHotlink';
|
||||
import { UploadZk } from './entities/UploadZk';
|
||||
import { UploadCleanupService } from './services/UploadCleanupService';
|
||||
import { UploadService } from './services/UploadService';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Upload, UploadZk, UploadHotlink]), StorageModule],
|
||||
controllers: [UploadController, HotlinkImageController],
|
||||
providers: [UploadService]
|
||||
providers: [UploadService, UploadCleanupService]
|
||||
})
|
||||
export class UploadModule {}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
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 { UploadCleanupService } from '../services/UploadCleanupService';
|
||||
import { UploadService } from '../services/UploadService';
|
||||
|
||||
@Controller('uploads')
|
||||
export class UploadController {
|
||||
constructor(private readonly uploadService: UploadService) {}
|
||||
constructor(
|
||||
private readonly uploadService: UploadService,
|
||||
private readonly uploadCleanupService: UploadCleanupService
|
||||
) {}
|
||||
|
||||
@Post('zk')
|
||||
@Throttle(throttleProfiles.upload)
|
||||
@@ -25,4 +30,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.uploadCleanupService.deleteUpload(id, delete_token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class DeleteUploadQueryDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
delete_token: string;
|
||||
}
|
||||
@@ -32,9 +32,6 @@ export class Upload {
|
||||
@OneToOne(() => UploadHotlink, hotlink => hotlink.upload, { cascade: true })
|
||||
hotlink: UploadHotlink | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
lastViewedAt: Date | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
expiresAt: Date | null;
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import type { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import { hashDeleteToken } from '../../../utils/hashDeleteToken';
|
||||
import type { Upload } from '../entities/Upload';
|
||||
import { StorageService } from '../../storage/services/StorageService';
|
||||
import { UploadCleanupService } from './UploadCleanupService';
|
||||
|
||||
jest.mock('@nestjs/typeorm', () => ({
|
||||
InjectDataSource: () => (): void => undefined,
|
||||
InjectRepository: () => (): void => undefined
|
||||
}));
|
||||
|
||||
const fixedId = '11111111-1111-4111-8111-111111111111';
|
||||
const fixedDeleteToken = 'fixed-delete-token';
|
||||
|
||||
describe('UploadCleanupService', () => {
|
||||
let service: UploadCleanupService;
|
||||
let storageService: jest.Mocked<Pick<StorageService, 'unlink'>>;
|
||||
let dataSource: jest.Mocked<Pick<DataSource, 'transaction'>>;
|
||||
let uploadRepository: {
|
||||
createQueryBuilder: jest.Mock;
|
||||
findOne: jest.Mock;
|
||||
remove: jest.Mock;
|
||||
};
|
||||
let getMany: jest.Mock;
|
||||
let take: jest.Mock;
|
||||
let transaction: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
getMany = jest.fn().mockResolvedValue([]);
|
||||
take = jest.fn().mockReturnValue({ getMany });
|
||||
const orWhere = jest.fn().mockReturnValue({ take });
|
||||
const where = jest.fn().mockReturnValue({ orWhere });
|
||||
|
||||
uploadRepository = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue({ where }),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
|
||||
transaction = jest.fn(async (runInTransaction: (manager: EntityManager) => Promise<unknown>) => {
|
||||
const manager = {
|
||||
getRepository: jest.fn(() => uploadRepository)
|
||||
} as unknown as EntityManager;
|
||||
|
||||
return runInTransaction(manager);
|
||||
});
|
||||
|
||||
dataSource = { transaction };
|
||||
|
||||
storageService = {
|
||||
unlink: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
|
||||
service = new UploadCleanupService(
|
||||
dataSource as unknown as DataSource,
|
||||
storageService as unknown as StorageService,
|
||||
uploadRepository as unknown as Repository<Upload>
|
||||
);
|
||||
});
|
||||
|
||||
describe('purgeUploads', () => {
|
||||
it('purges each eligible upload up to the batch size', async () => {
|
||||
const expired = { id: fixedId, storagePath: fixedId };
|
||||
const viewCapped = {
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
storagePath: '22222222-2222-4222-8222-222222222222'
|
||||
};
|
||||
|
||||
getMany.mockResolvedValue([expired, viewCapped]);
|
||||
|
||||
await expect(service.purgeUploads()).resolves.toBeUndefined();
|
||||
|
||||
expect(uploadRepository.createQueryBuilder).toHaveBeenCalledWith('upload');
|
||||
expect(take).toHaveBeenCalledWith(100);
|
||||
expect(storageService.unlink).toHaveBeenCalledWith(fixedId);
|
||||
expect(storageService.unlink).toHaveBeenCalledWith(viewCapped.storagePath);
|
||||
expect(uploadRepository.remove).toHaveBeenCalledWith(expired);
|
||||
expect(uploadRepository.remove).toHaveBeenCalledWith(viewCapped);
|
||||
});
|
||||
|
||||
it('does nothing when no uploads are eligible', async () => {
|
||||
getMany.mockResolvedValue([]);
|
||||
|
||||
await expect(service.purgeUploads()).resolves.toBeUndefined();
|
||||
|
||||
expect(storageService.unlink).not.toHaveBeenCalled();
|
||||
expect(uploadRepository.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still removes row when ciphertext file is already missing', async () => {
|
||||
const upload = { id: fixedId, storagePath: fixedId };
|
||||
|
||||
getMany.mockResolvedValue([upload]);
|
||||
storageService.unlink.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
await expect(service.purgeUploads()).resolves.toBeUndefined();
|
||||
|
||||
expect(uploadRepository.remove).toHaveBeenCalledWith(upload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUpload', () => {
|
||||
it('unlinks ciphertext and removes upload when delete token matches', async () => {
|
||||
const upload = {
|
||||
storagePath: fixedId,
|
||||
deleteTokenHash: hashDeleteToken(fixedDeleteToken)
|
||||
};
|
||||
|
||||
uploadRepository.findOne.mockResolvedValue(upload);
|
||||
|
||||
await expect(service.deleteUpload(fixedId, fixedDeleteToken)).resolves.toBeUndefined();
|
||||
|
||||
expect(uploadRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: fixedId, deleteTokenHash: hashDeleteToken(fixedDeleteToken) }
|
||||
});
|
||||
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, fixedDeleteToken)).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(fixedDeleteToken)
|
||||
};
|
||||
|
||||
uploadRepository.findOne.mockResolvedValue(upload);
|
||||
storageService.unlink.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
await expect(service.deleteUpload(fixedId, fixedDeleteToken)).resolves.toBeUndefined();
|
||||
|
||||
expect(uploadRepository.remove).toHaveBeenCalledWith(upload);
|
||||
});
|
||||
|
||||
it('propagates non-ENOENT storage unlink errors', async () => {
|
||||
uploadRepository.findOne.mockResolvedValue({
|
||||
storagePath: fixedId,
|
||||
deleteTokenHash: hashDeleteToken(fixedDeleteToken)
|
||||
});
|
||||
storageService.unlink.mockRejectedValue(new Error('permission denied'));
|
||||
|
||||
await expect(service.deleteUpload(fixedId, fixedDeleteToken)).rejects.toThrow('permission denied');
|
||||
expect(uploadRepository.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('purgeUpload', () => {
|
||||
it('unlinks ciphertext and removes upload', async () => {
|
||||
const upload = { id: fixedId, storagePath: fixedId };
|
||||
|
||||
await expect(
|
||||
service.purgeUpload(upload as never, uploadRepository as never)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(storageService.unlink).toHaveBeenCalledWith(fixedId);
|
||||
expect(uploadRepository.remove).toHaveBeenCalledWith(upload);
|
||||
});
|
||||
|
||||
it('still removes row when ciphertext file is already missing', async () => {
|
||||
const upload = { id: fixedId, storagePath: fixedId };
|
||||
|
||||
storageService.unlink.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
await expect(
|
||||
service.purgeUpload(upload as never, uploadRepository as never)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(uploadRepository.remove).toHaveBeenCalledWith(upload);
|
||||
});
|
||||
|
||||
it('propagates non-ENOENT storage unlink errors', async () => {
|
||||
const upload = { id: fixedId, storagePath: fixedId };
|
||||
|
||||
storageService.unlink.mockRejectedValue(new Error('permission denied'));
|
||||
|
||||
await expect(service.purgeUpload(upload as never, uploadRepository as never)).rejects.toThrow(
|
||||
'permission denied'
|
||||
);
|
||||
expect(uploadRepository.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { hashDeleteToken } from '../../../utils/hashDeleteToken';
|
||||
import { isErrorEnoent } from '../../../utils/isErrorEnoent';
|
||||
import { StorageService } from '../../storage/services/StorageService';
|
||||
import { Upload } from '../entities/Upload';
|
||||
|
||||
@Injectable()
|
||||
export class UploadCleanupService {
|
||||
constructor(
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly storageService: StorageService,
|
||||
@InjectRepository(Upload)
|
||||
private readonly uploadRepository: Repository<Upload>
|
||||
) {}
|
||||
|
||||
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: hashDeleteToken(deleteToken) }
|
||||
});
|
||||
|
||||
if (!upload) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await this.purgeUpload(upload, uploadRepository);
|
||||
});
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async purgeUploads(): Promise<void> {
|
||||
const purgeBatchSize = 100;
|
||||
const now = new Date();
|
||||
|
||||
const uploads = await this.uploadRepository
|
||||
.createQueryBuilder('upload')
|
||||
.where('upload.expiresAt IS NOT NULL AND upload.expiresAt < :now', { now })
|
||||
.orWhere('upload.maxViews IS NOT NULL AND upload.viewCount >= upload.maxViews')
|
||||
.take(purgeBatchSize)
|
||||
.getMany();
|
||||
|
||||
for (const upload of uploads) {
|
||||
await this.purgeUpload(upload, this.uploadRepository);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
import { createCipheriv, randomBytes } from 'node:crypto';
|
||||
import { hashDeleteToken } from '../../../utils/hashDeleteToken';
|
||||
import { AES_GCM_IV_BYTE_LENGTH } from '../../../consts/crypto/aesGcmIvByteLength';
|
||||
import type { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import type { Upload } from '../entities/Upload';
|
||||
@@ -39,8 +40,6 @@ const ciphertextBase64 = (fill = 0xcd): string => Buffer.alloc(64, fill).toStrin
|
||||
|
||||
const wrapFieldBase64 = (length: number): string => Buffer.alloc(length, 0x22).toString('base64');
|
||||
|
||||
const hashDeleteToken = (token: string): string => createHash('sha256').update(token, 'utf8').digest('hex');
|
||||
|
||||
const encryptHotlinkCiphertext = (plaintext: Buffer, key: Buffer, iv: Buffer): Buffer => {
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
@@ -50,15 +49,30 @@ 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 };
|
||||
let storageService: jest.Mocked<Pick<StorageService, 'write' | 'read' | 'unlink'>>;
|
||||
let uploadRepository: {
|
||||
create: jest.Mock;
|
||||
save: jest.Mock;
|
||||
findOne: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
remove: jest.Mock;
|
||||
};
|
||||
let transaction: jest.Mock;
|
||||
let reserveViewExecute: jest.Mock;
|
||||
|
||||
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 = {
|
||||
create: jest.fn((entityLike: unknown) => entityLike),
|
||||
save: jest.fn(async (entity: unknown) => entity),
|
||||
findOne: jest.fn()
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn().mockReturnValue({ update }),
|
||||
remove: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
|
||||
transaction = jest.fn(async (runInTransaction: (manager: EntityManager) => Promise<unknown>) => {
|
||||
@@ -72,7 +86,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);
|
||||
@@ -224,6 +239,7 @@ describe('UploadService', () => {
|
||||
relations: ['zk']
|
||||
});
|
||||
expect(storageService.read).toHaveBeenCalledWith(fixedId);
|
||||
expect(reserveViewExecute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes wrap fields when password protected', async () => {
|
||||
@@ -253,6 +269,7 @@ describe('UploadService', () => {
|
||||
wrapIv: wrapIv.toString('base64'),
|
||||
salt: salt.toString('base64')
|
||||
});
|
||||
expect(reserveViewExecute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when upload is missing or not zk', async () => {
|
||||
@@ -260,6 +277,7 @@ describe('UploadService', () => {
|
||||
|
||||
await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException);
|
||||
expect(storageService.read).not.toHaveBeenCalled();
|
||||
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when ciphertext file is missing', async () => {
|
||||
@@ -271,6 +289,7 @@ describe('UploadService', () => {
|
||||
storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException);
|
||||
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when password protected but wrap data is incomplete', async () => {
|
||||
@@ -287,6 +306,75 @@ describe('UploadService', () => {
|
||||
storageService.read.mockResolvedValue(Buffer.alloc(8));
|
||||
|
||||
await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException);
|
||||
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when expiresAt is in the past', async () => {
|
||||
uploadRepository.findOne.mockResolvedValue({
|
||||
storagePath: fixedId,
|
||||
iv: Buffer.alloc(12, 0x11),
|
||||
expiresAt: new Date(Date.now() - 60_000),
|
||||
zk: {
|
||||
passwordProtected: false,
|
||||
wrappedDek: null,
|
||||
wrapIv: null,
|
||||
salt: null
|
||||
}
|
||||
});
|
||||
|
||||
await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException);
|
||||
expect(storageService.read).not.toHaveBeenCalled();
|
||||
expect(reserveViewExecute).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(reserveViewExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not purge on GET 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(reserveViewExecute).toHaveBeenCalled();
|
||||
expect(storageService.unlink).not.toHaveBeenCalled();
|
||||
expect(uploadRepository.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -314,6 +402,7 @@ describe('UploadService', () => {
|
||||
where: { id: fixedId, mode: UploadMode.Hotlink },
|
||||
relations: ['hotlink']
|
||||
});
|
||||
expect(reserveViewExecute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when upload is missing or not hotlink', async () => {
|
||||
@@ -321,6 +410,7 @@ describe('UploadService', () => {
|
||||
|
||||
await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException);
|
||||
expect(storageService.read).not.toHaveBeenCalled();
|
||||
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when secret is wrong but well-formed', async () => {
|
||||
@@ -334,6 +424,7 @@ describe('UploadService', () => {
|
||||
const wrongSecret = Buffer.alloc(32, 0x99).toString('base64url');
|
||||
|
||||
await expect(service.getHotlinkImage(fixedId, wrongSecret)).rejects.toThrow(NotFoundException);
|
||||
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when ciphertext file is missing', async () => {
|
||||
@@ -345,6 +436,77 @@ describe('UploadService', () => {
|
||||
storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException);
|
||||
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when expiresAt is in the past', async () => {
|
||||
uploadRepository.findOne.mockResolvedValue({
|
||||
storagePath: fixedId,
|
||||
iv,
|
||||
expiresAt: new Date(Date.now() - 60_000),
|
||||
hotlink: { mimeType: 'image/png' }
|
||||
});
|
||||
|
||||
await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException);
|
||||
expect(storageService.read).not.toHaveBeenCalled();
|
||||
expect(reserveViewExecute).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(reserveViewExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not purge on GET 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(reserveViewExecute).toHaveBeenCalled();
|
||||
expect(storageService.unlink).not.toHaveBeenCalled();
|
||||
expect(uploadRepository.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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(reserveViewExecute).toHaveBeenCalled();
|
||||
expect(storageService.unlink).not.toHaveBeenCalled();
|
||||
expect(uploadRepository.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import dayjs from 'dayjs';
|
||||
import { fileTypeFromBuffer } from 'file-type';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { decryptAesGcm } from '../../../utils/decryptAesGcm';
|
||||
import { fromBase64 } from '../../../utils/fromBase64';
|
||||
import { fromBase64Url } from '../../../utils/fromBase64Url';
|
||||
import { hashDeleteToken } from '../../../utils/hashDeleteToken';
|
||||
import { isErrorEnoent } from '../../../utils/isErrorEnoent';
|
||||
import { toBase64 } from '../../../utils/toBase64';
|
||||
import { StorageService } from '../../storage/services/StorageService';
|
||||
@@ -54,7 +56,7 @@ export class UploadService {
|
||||
storagePath,
|
||||
iv: fromBase64(iv),
|
||||
maxViews,
|
||||
deleteTokenHash: this.hashDeleteToken(deleteToken),
|
||||
deleteTokenHash: hashDeleteToken(deleteToken),
|
||||
expiresAt,
|
||||
zk: {
|
||||
passwordProtected,
|
||||
@@ -100,7 +102,7 @@ export class UploadService {
|
||||
storagePath,
|
||||
iv: fromBase64(iv),
|
||||
maxViews,
|
||||
deleteTokenHash: this.hashDeleteToken(deleteToken),
|
||||
deleteTokenHash: hashDeleteToken(deleteToken),
|
||||
expiresAt,
|
||||
hotlink: { mimeType }
|
||||
});
|
||||
@@ -127,6 +129,14 @@ export class UploadService {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (upload.expiresAt && dayjs(upload.expiresAt).isBefore(dayjs())) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (typeof upload.maxViews === 'number' && upload.viewCount >= upload.maxViews) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
let ciphertextBuffer: Buffer;
|
||||
|
||||
try {
|
||||
@@ -155,6 +165,8 @@ export class UploadService {
|
||||
result.salt = toBase64(upload.zk.salt);
|
||||
}
|
||||
|
||||
await this.reserveViewSlot(id);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -168,6 +180,14 @@ export class UploadService {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (upload.expiresAt && dayjs(upload.expiresAt).isBefore(dayjs())) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (typeof upload.maxViews === 'number' && upload.viewCount >= upload.maxViews) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
let ciphertextBuffer: Buffer;
|
||||
|
||||
try {
|
||||
@@ -190,14 +210,26 @@ export class UploadService {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await this.reserveViewSlot(id);
|
||||
|
||||
return {
|
||||
body,
|
||||
mimeType: upload.hotlink.mimeType
|
||||
};
|
||||
}
|
||||
|
||||
private hashDeleteToken(token: string): string {
|
||||
return createHash('sha256').update(token, 'utf8').digest('hex');
|
||||
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> {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const hashDeleteToken = (token: string): string =>
|
||||
createHash('sha256').update(token, 'utf8').digest('hex');
|
||||
@@ -96,6 +96,12 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
return data;
|
||||
};
|
||||
|
||||
const deleteUpload = async (uploadId: string, deleteToken: string): Promise<void> => {
|
||||
await api.delete(`/uploads/${uploadId}`, {
|
||||
params: { delete_token: deleteToken }
|
||||
});
|
||||
};
|
||||
|
||||
const getUploadRequestHeaders = (altcha?: string) => (altcha ? { [ALTCHA_REQUEST_HEADER]: altcha } : undefined);
|
||||
|
||||
const uploadBatch = async (
|
||||
@@ -257,6 +263,7 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
clearAllUploads,
|
||||
uploadBatch,
|
||||
uploadZk,
|
||||
uploadHotlink
|
||||
uploadHotlink,
|
||||
deleteUpload
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type DeleteViewState = 'invalid' | 'deleting' | 'deleted' | 'notFound' | 'error';
|
||||
@@ -1,6 +1,59 @@
|
||||
<template>
|
||||
<main class="flex min-h-screen flex-col p-8">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Delete</h1>
|
||||
<p class="mt-2 text-muted-foreground">Delete upload — coming soon.</p>
|
||||
<main class="mx-auto flex min-h-screen max-w-lg flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<p v-if="state === 'invalid'" class="text-sm text-muted-foreground">
|
||||
This delete link is incomplete. Use the delete URL from your upload results.
|
||||
</p>
|
||||
|
||||
<p v-else-if="state === 'deleting'" class="text-sm text-muted-foreground">Deleting image...</p>
|
||||
|
||||
<p v-else-if="state === 'deleted'" class="text-sm text-muted-foreground"> The image has been deleted. </p>
|
||||
|
||||
<p v-else-if="state === 'notFound'" class="text-sm text-muted-foreground"> This image was not found. </p>
|
||||
|
||||
<p v-else-if="state === 'error'" class="text-sm text-destructive">
|
||||
Could not delete this image. Try again later.
|
||||
</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import axios, { HttpStatusCode } from 'axios';
|
||||
import { onBeforeMount, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useUploadStore } from '@/stores/upload';
|
||||
import type { DeleteViewState } from '@/types/delete/DeleteViewState';
|
||||
|
||||
const route = useRoute();
|
||||
const uploadStore = useUploadStore();
|
||||
|
||||
const state = ref<DeleteViewState>('deleting');
|
||||
|
||||
onBeforeMount(async () => {
|
||||
const uploadId = route.params.id;
|
||||
const deleteToken = route.query.token;
|
||||
|
||||
if (typeof uploadId !== 'string' || !uploadId || typeof deleteToken !== 'string' || !deleteToken) {
|
||||
state.value = 'invalid';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
state.value = 'deleting';
|
||||
|
||||
try {
|
||||
await uploadStore.deleteUpload(uploadId, deleteToken);
|
||||
|
||||
state.value = 'deleted';
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) {
|
||||
state.value = 'notFound';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('[delete] failed', { uploadId, error });
|
||||
|
||||
state.value = 'error';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user