diff --git a/backend/src/database/migrations/1791230839247-drop-upload-last-viewed-at.ts b/backend/src/database/migrations/1791230839247-drop-upload-last-viewed-at.ts new file mode 100644 index 0000000..e18bba3 --- /dev/null +++ b/backend/src/database/migrations/1791230839247-drop-upload-last-viewed-at.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DropUploadLastViewedAt1791230839247 implements MigrationInterface { + name = 'DropUploadLastViewedAt1791230839247'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "uploads" DROP COLUMN "lastViewedAt"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "uploads" ADD "lastViewedAt" TIMESTAMP WITH TIME ZONE`); + } +} diff --git a/backend/src/modules/upload/UploadModule.ts b/backend/src/modules/upload/UploadModule.ts index 0ba3a09..fea22ef 100644 --- a/backend/src/modules/upload/UploadModule.ts +++ b/backend/src/modules/upload/UploadModule.ts @@ -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 {} diff --git a/backend/src/modules/upload/controllers/UploadController.ts b/backend/src/modules/upload/controllers/UploadController.ts index b6fae3c..3c85f62 100644 --- a/backend/src/modules/upload/controllers/UploadController.ts +++ b/backend/src/modules/upload/controllers/UploadController.ts @@ -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); + } } diff --git a/backend/src/modules/upload/dto/DeleteUploadQueryDto.ts b/backend/src/modules/upload/dto/DeleteUploadQueryDto.ts new file mode 100644 index 0000000..003d89c --- /dev/null +++ b/backend/src/modules/upload/dto/DeleteUploadQueryDto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class DeleteUploadQueryDto { + @IsNotEmpty() + @IsString() + delete_token: string; +} diff --git a/backend/src/modules/upload/entities/Upload.ts b/backend/src/modules/upload/entities/Upload.ts index 45807e1..0d59e24 100644 --- a/backend/src/modules/upload/entities/Upload.ts +++ b/backend/src/modules/upload/entities/Upload.ts @@ -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; diff --git a/backend/src/modules/upload/services/UploadCleanupService.spec.ts b/backend/src/modules/upload/services/UploadCleanupService.spec.ts new file mode 100644 index 0000000..8f79ff3 --- /dev/null +++ b/backend/src/modules/upload/services/UploadCleanupService.spec.ts @@ -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>; + let dataSource: jest.Mocked>; + 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) => { + 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 + ); + }); + + 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(); + }); + }); +}); diff --git a/backend/src/modules/upload/services/UploadCleanupService.ts b/backend/src/modules/upload/services/UploadCleanupService.ts new file mode 100644 index 0000000..167185e --- /dev/null +++ b/backend/src/modules/upload/services/UploadCleanupService.ts @@ -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 + ) {} + + async deleteUpload(id: string, deleteToken: string): Promise { + 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 { + 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): Promise { + try { + await this.storageService.unlink(upload.storagePath); + } catch (error: unknown) { + if (!isErrorEnoent(error)) { + throw error; + } + } + + await uploadRepository.remove(upload); + } +} diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts index e1e8fbc..299d8bf 100644 --- a/backend/src/modules/upload/services/UploadService.spec.ts +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -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>; - let storageService: jest.Mocked>; - let uploadRepository: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock }; + let storageService: jest.Mocked>; + 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) => { @@ -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(); }); }); diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index e50efaf..fa2a9ef 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -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 { + 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 { diff --git a/backend/src/utils/hashDeleteToken.ts b/backend/src/utils/hashDeleteToken.ts new file mode 100644 index 0000000..74e6256 --- /dev/null +++ b/backend/src/utils/hashDeleteToken.ts @@ -0,0 +1,4 @@ +import { createHash } from 'node:crypto'; + +export const hashDeleteToken = (token: string): string => + createHash('sha256').update(token, 'utf8').digest('hex'); diff --git a/frontend/src/stores/upload.ts b/frontend/src/stores/upload.ts index 459dd94..ab5c819 100644 --- a/frontend/src/stores/upload.ts +++ b/frontend/src/stores/upload.ts @@ -96,6 +96,12 @@ export const useUploadStore = defineStore('upload', () => { return data; }; + const deleteUpload = async (uploadId: string, deleteToken: string): Promise => { + 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 }; }); diff --git a/frontend/src/types/delete/DeleteViewState.ts b/frontend/src/types/delete/DeleteViewState.ts new file mode 100644 index 0000000..3cb0844 --- /dev/null +++ b/frontend/src/types/delete/DeleteViewState.ts @@ -0,0 +1 @@ +export type DeleteViewState = 'invalid' | 'deleting' | 'deleted' | 'notFound' | 'error'; diff --git a/frontend/src/views/DeleteView.vue b/frontend/src/views/DeleteView.vue index 04e61e3..d2945c7 100644 --- a/frontend/src/views/DeleteView.vue +++ b/frontend/src/views/DeleteView.vue @@ -1,6 +1,59 @@ + +