Merge pull request 'Slave/lifecycle' (#11) from slave/lifecycle into master

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-10-27 21:12:03 +00:00
13 changed files with 572 additions and 22 deletions
@@ -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`);
}
}
+2 -1
View File
@@ -6,11 +6,12 @@ import { UploadController } from './controllers/UploadController';
import { Upload } from './entities/Upload'; import { Upload } from './entities/Upload';
import { UploadHotlink } from './entities/UploadHotlink'; import { UploadHotlink } from './entities/UploadHotlink';
import { UploadZk } from './entities/UploadZk'; import { UploadZk } from './entities/UploadZk';
import { UploadCleanupService } from './services/UploadCleanupService';
import { UploadService } from './services/UploadService'; import { UploadService } from './services/UploadService';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Upload, UploadZk, UploadHotlink]), StorageModule], imports: [TypeOrmModule.forFeature([Upload, UploadZk, UploadHotlink]), StorageModule],
controllers: [UploadController, HotlinkImageController], controllers: [UploadController, HotlinkImageController],
providers: [UploadService] providers: [UploadService, UploadCleanupService]
}) })
export class UploadModule {} 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 { 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 { UploadCleanupService } from '../services/UploadCleanupService';
import { UploadService } from '../services/UploadService'; import { UploadService } from '../services/UploadService';
@Controller('uploads') @Controller('uploads')
export class UploadController { export class UploadController {
constructor(private readonly uploadService: UploadService) {} constructor(
private readonly uploadService: UploadService,
private readonly uploadCleanupService: UploadCleanupService
) {}
@Post('zk') @Post('zk')
@Throttle(throttleProfiles.upload) @Throttle(throttleProfiles.upload)
@@ -25,4 +30,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.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 }) @OneToOne(() => UploadHotlink, hotlink => hotlink.upload, { cascade: true })
hotlink: UploadHotlink | null; hotlink: UploadHotlink | null;
@Column({ type: 'timestamptz', nullable: true })
lastViewedAt: Date | null;
@Column({ type: 'timestamptz', nullable: true }) @Column({ type: 'timestamptz', nullable: true })
expiresAt: Date | null; 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 { 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 { AES_GCM_IV_BYTE_LENGTH } from '../../../consts/crypto/aesGcmIvByteLength';
import type { DataSource, EntityManager, Repository } from 'typeorm'; import type { DataSource, EntityManager, Repository } from 'typeorm';
import type { Upload } from '../entities/Upload'; 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 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 encryptHotlinkCiphertext = (plaintext: Buffer, key: Buffer, iv: Buffer): Buffer => {
const cipher = createCipheriv('aes-256-gcm', key, iv); const cipher = createCipheriv('aes-256-gcm', key, iv);
@@ -50,15 +49,30 @@ 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 }; let uploadRepository: {
create: jest.Mock;
save: jest.Mock;
findOne: jest.Mock;
createQueryBuilder: 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(),
createQueryBuilder: jest.fn().mockReturnValue({ update }),
remove: jest.fn().mockResolvedValue(undefined)
}; };
transaction = jest.fn(async (runInTransaction: (manager: EntityManager) => Promise<unknown>) => { transaction = jest.fn(async (runInTransaction: (manager: EntityManager) => Promise<unknown>) => {
@@ -72,7 +86,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);
@@ -224,6 +239,7 @@ describe('UploadService', () => {
relations: ['zk'] relations: ['zk']
}); });
expect(storageService.read).toHaveBeenCalledWith(fixedId); expect(storageService.read).toHaveBeenCalledWith(fixedId);
expect(reserveViewExecute).toHaveBeenCalled();
}); });
it('includes wrap fields when password protected', async () => { it('includes wrap fields when password protected', async () => {
@@ -253,6 +269,7 @@ describe('UploadService', () => {
wrapIv: wrapIv.toString('base64'), wrapIv: wrapIv.toString('base64'),
salt: salt.toString('base64') salt: salt.toString('base64')
}); });
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 () => {
@@ -260,6 +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(reserveViewExecute).not.toHaveBeenCalled();
}); });
it('throws NotFoundException when ciphertext file is missing', async () => { 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' })); 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(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 () => {
@@ -287,6 +306,75 @@ 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(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 }, where: { id: fixedId, mode: UploadMode.Hotlink },
relations: ['hotlink'] relations: ['hotlink']
}); });
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 () => {
@@ -321,6 +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(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 () => {
@@ -334,6 +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(reserveViewExecute).not.toHaveBeenCalled();
}); });
it('throws NotFoundException when ciphertext file is missing', async () => { 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' })); 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(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 { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import dayjs from 'dayjs';
import { fileTypeFromBuffer } from 'file-type'; import { fileTypeFromBuffer } from 'file-type';
import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { randomBytes, randomUUID } from 'node:crypto';
import { DataSource, Repository } from 'typeorm'; import { DataSource, Repository } from 'typeorm';
import { decryptAesGcm } from '../../../utils/decryptAesGcm'; import { decryptAesGcm } from '../../../utils/decryptAesGcm';
import { fromBase64 } from '../../../utils/fromBase64'; import { fromBase64 } from '../../../utils/fromBase64';
import { fromBase64Url } from '../../../utils/fromBase64Url'; import { fromBase64Url } from '../../../utils/fromBase64Url';
import { hashDeleteToken } from '../../../utils/hashDeleteToken';
import { isErrorEnoent } from '../../../utils/isErrorEnoent'; import { isErrorEnoent } from '../../../utils/isErrorEnoent';
import { toBase64 } from '../../../utils/toBase64'; import { toBase64 } from '../../../utils/toBase64';
import { StorageService } from '../../storage/services/StorageService'; import { StorageService } from '../../storage/services/StorageService';
@@ -54,7 +56,7 @@ export class UploadService {
storagePath, storagePath,
iv: fromBase64(iv), iv: fromBase64(iv),
maxViews, maxViews,
deleteTokenHash: this.hashDeleteToken(deleteToken), deleteTokenHash: hashDeleteToken(deleteToken),
expiresAt, expiresAt,
zk: { zk: {
passwordProtected, passwordProtected,
@@ -100,7 +102,7 @@ export class UploadService {
storagePath, storagePath,
iv: fromBase64(iv), iv: fromBase64(iv),
maxViews, maxViews,
deleteTokenHash: this.hashDeleteToken(deleteToken), deleteTokenHash: hashDeleteToken(deleteToken),
expiresAt, expiresAt,
hotlink: { mimeType } hotlink: { mimeType }
}); });
@@ -127,6 +129,14 @@ export class UploadService {
throw new NotFoundException(); 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; let ciphertextBuffer: Buffer;
try { try {
@@ -155,6 +165,8 @@ export class UploadService {
result.salt = toBase64(upload.zk.salt); result.salt = toBase64(upload.zk.salt);
} }
await this.reserveViewSlot(id);
return result; return result;
} }
@@ -168,6 +180,14 @@ export class UploadService {
throw new NotFoundException(); 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; let ciphertextBuffer: Buffer;
try { try {
@@ -190,14 +210,26 @@ export class UploadService {
throw new NotFoundException(); throw new NotFoundException();
} }
await this.reserveViewSlot(id);
return { return {
body, body,
mimeType: upload.hotlink.mimeType mimeType: upload.hotlink.mimeType
}; };
} }
private hashDeleteToken(token: string): string { private async reserveViewSlot(id: string): Promise<void> {
return createHash('sha256').update(token, 'utf8').digest('hex'); 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> {
+4
View File
@@ -0,0 +1,4 @@
import { createHash } from 'node:crypto';
export const hashDeleteToken = (token: string): string =>
createHash('sha256').update(token, 'utf8').digest('hex');
+8 -1
View File
@@ -96,6 +96,12 @@ export const useUploadStore = defineStore('upload', () => {
return data; 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 getUploadRequestHeaders = (altcha?: string) => (altcha ? { [ALTCHA_REQUEST_HEADER]: altcha } : undefined);
const uploadBatch = async ( const uploadBatch = async (
@@ -257,6 +263,7 @@ export const useUploadStore = defineStore('upload', () => {
clearAllUploads, clearAllUploads,
uploadBatch, uploadBatch,
uploadZk, uploadZk,
uploadHotlink uploadHotlink,
deleteUpload
}; };
}); });
@@ -0,0 +1 @@
export type DeleteViewState = 'invalid' | 'deleting' | 'deleted' | 'notFound' | 'error';
+56 -3
View File
@@ -1,6 +1,59 @@
<template> <template>
<main class="flex min-h-screen flex-col p-8"> <main class="mx-auto flex min-h-screen max-w-lg flex-col items-center justify-center gap-3 p-8 text-center">
<h1 class="text-2xl font-semibold tracking-tight">Delete</h1> <p v-if="state === 'invalid'" class="text-sm text-muted-foreground">
<p class="mt-2 text-muted-foreground">Delete upload — coming soon.</p> 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> </main>
</template> </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>