diff --git a/backend/src/modules/storage/StorageModule.ts b/backend/src/modules/storage/StorageModule.ts index 9497dcd..1ea11c8 100644 --- a/backend/src/modules/storage/StorageModule.ts +++ b/backend/src/modules/storage/StorageModule.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { StorageService } from './services/StorageService'; @Module({ - providers: [StorageService] + providers: [StorageService], + exports: [StorageService] }) export class StorageModule {} diff --git a/backend/src/modules/upload/UploadModule.ts b/backend/src/modules/upload/UploadModule.ts index 788ff16..29c42bb 100644 --- a/backend/src/modules/upload/UploadModule.ts +++ b/backend/src/modules/upload/UploadModule.ts @@ -1,10 +1,15 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { StorageModule } from '../storage/StorageModule'; +import { UploadController } from './controllers/UploadController'; import { Upload } from './entities/Upload'; import { UploadHotlink } from './entities/UploadHotlink'; import { UploadZk } from './entities/UploadZk'; +import { UploadService } from './services/UploadService'; @Module({ - imports: [TypeOrmModule.forFeature([Upload, UploadZk, UploadHotlink])] + imports: [TypeOrmModule.forFeature([Upload, UploadZk, UploadHotlink]), StorageModule], + controllers: [UploadController], + providers: [UploadService] }) export class UploadModule {} diff --git a/backend/src/modules/upload/controllers/UploadController.ts b/backend/src/modules/upload/controllers/UploadController.ts new file mode 100644 index 0000000..04d6754 --- /dev/null +++ b/backend/src/modules/upload/controllers/UploadController.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import { throttleProfiles } from '../../../config/throttleProfiles'; +import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto'; +import { CreateZkUploadDto } from '../dto/CreateZkUploadDto'; +import { UploadService } from '../services/UploadService'; + +@Controller('uploads') +export class UploadController { + constructor(private readonly uploadService: UploadService) {} + + @Post('zk') + @Throttle(throttleProfiles.upload) + createZk(@Body() dto: CreateZkUploadDto) { + return this.uploadService.createZkUpload(dto); + } + + @Post('hotlink') + @Throttle(throttleProfiles.upload) + createHotlink(@Body() dto: CreateHotlinkUploadDto) { + return this.uploadService.createHotlinkUpload(dto); + } +} diff --git a/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts b/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts index 9d851bd..9bc16de 100644 --- a/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts +++ b/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts @@ -1,19 +1,9 @@ -import { Type } from 'class-transformer'; -import { ArrayMaxSize, ArrayMinSize, IsArray, IsNotEmpty, ValidateNested } from 'class-validator'; -import { getAppConfig } from '../../../config'; -import { CreateHotlinkUploadItemDto } from './CreateHotlinkUploadItemDto'; -import { UploadBatchOptionsDto } from './UploadBatchOptionsDto'; +import { IsMimeType, IsNotEmpty, IsString } from 'class-validator'; +import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto'; -const { - validation: { maxImagesPerUpload } -} = getAppConfig(); - -export class CreateHotlinkUploadDto extends UploadBatchOptionsDto { +export class CreateHotlinkUploadDto extends UploadCiphertextOptionsDto { @IsNotEmpty() - @IsArray() - @ArrayMinSize(1) - @ArrayMaxSize(maxImagesPerUpload) - @ValidateNested({ each: true }) - @Type(() => CreateHotlinkUploadItemDto) - items: CreateHotlinkUploadItemDto[]; + @IsString() + @IsMimeType() + mimeType: string; } diff --git a/backend/src/modules/upload/dto/CreateHotlinkUploadItemDto.ts b/backend/src/modules/upload/dto/CreateHotlinkUploadItemDto.ts deleted file mode 100644 index b14b8cf..0000000 --- a/backend/src/modules/upload/dto/CreateHotlinkUploadItemDto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IsMimeType, IsNotEmpty, IsString } from 'class-validator'; -import { CreateUploadCiphertextItemDto } from './CreateUploadCiphertextItemDto'; - -export class CreateHotlinkUploadItemDto extends CreateUploadCiphertextItemDto { - @IsNotEmpty() - @IsString() - @IsMimeType() - mimeType: string; -} diff --git a/backend/src/modules/upload/dto/CreateUploadCiphertextItemDto.ts b/backend/src/modules/upload/dto/CreateUploadCiphertextItemDto.ts deleted file mode 100644 index cba342c..0000000 --- a/backend/src/modules/upload/dto/CreateUploadCiphertextItemDto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IsNotEmpty } from 'class-validator'; -import { IsBase64 } from '../../../validation/decorators/isBase64'; - -export class CreateUploadCiphertextItemDto { - @IsNotEmpty() - @IsBase64({ byteLength: 12 }) - iv: string; - - @IsNotEmpty() - @IsBase64() - ciphertext: string; -} diff --git a/backend/src/modules/upload/dto/CreateZkPasswordUploadItemDto.ts b/backend/src/modules/upload/dto/CreateZkPasswordUploadItemDto.ts deleted file mode 100644 index fc3a5b1..0000000 --- a/backend/src/modules/upload/dto/CreateZkPasswordUploadItemDto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IsNotEmpty } from 'class-validator'; -import { IsBase64 } from '../../../validation/decorators/isBase64'; -import { CreateUploadCiphertextItemDto } from './CreateUploadCiphertextItemDto'; - -export class CreateZkPasswordUploadItemDto extends CreateUploadCiphertextItemDto { - @IsNotEmpty() - @IsBase64() - wrappedDek: string; - - @IsNotEmpty() - @IsBase64({ byteLength: 12 }) - wrapIv: string; - - @IsNotEmpty() - @IsBase64() - salt: string; -} diff --git a/backend/src/modules/upload/dto/CreateZkUploadDto.ts b/backend/src/modules/upload/dto/CreateZkUploadDto.ts index ccab0b6..9e5ca8f 100644 --- a/backend/src/modules/upload/dto/CreateZkUploadDto.ts +++ b/backend/src/modules/upload/dto/CreateZkUploadDto.ts @@ -1,40 +1,24 @@ -import { plainToInstance, Transform } from 'class-transformer'; -import { - ArrayMaxSize, - ArrayMinSize, - IsArray, - IsBoolean, - IsNotEmpty, - ValidateNested -} from 'class-validator'; -import { getAppConfig } from '../../../config'; -import { CreateUploadCiphertextItemDto } from './CreateUploadCiphertextItemDto'; -import { CreateZkPasswordUploadItemDto } from './CreateZkPasswordUploadItemDto'; -import { UploadBatchOptionsDto } from './UploadBatchOptionsDto'; +import { IsBoolean, IsNotEmpty, ValidateIf } from 'class-validator'; +import { IsBase64 } from '../../../validation/decorators/isBase64'; +import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto'; -const { - validation: { maxImagesPerUpload } -} = getAppConfig(); - -export class CreateZkUploadDto extends UploadBatchOptionsDto { +export class CreateZkUploadDto extends UploadCiphertextOptionsDto { @IsNotEmpty() @IsBoolean() passwordProtected: boolean; + @ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true) @IsNotEmpty() - @IsArray() - @ArrayMinSize(1) - @ArrayMaxSize(maxImagesPerUpload) - @ValidateNested({ each: true }) - @Transform(({ value, obj }: { value: unknown; obj: CreateZkUploadDto }) => { - if (!Array.isArray(value)) { - return value; - } + @IsBase64({ maxByteLength: 128 }) + wrappedDek?: string; - const ItemClass = - obj.passwordProtected === true ? CreateZkPasswordUploadItemDto : CreateUploadCiphertextItemDto; + @ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true) + @IsNotEmpty() + @IsBase64({ byteLength: 12 }) + wrapIv?: string; - return value.map(item => plainToInstance(ItemClass, item)); - }) - items: Array; + @ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true) + @IsNotEmpty() + @IsBase64({ maxByteLength: 64 }) + salt?: string; } diff --git a/backend/src/modules/upload/dto/UploadCiphertextOptionsDto.ts b/backend/src/modules/upload/dto/UploadCiphertextOptionsDto.ts new file mode 100644 index 0000000..5414cf9 --- /dev/null +++ b/backend/src/modules/upload/dto/UploadCiphertextOptionsDto.ts @@ -0,0 +1,20 @@ +import { IsNotEmpty } from 'class-validator'; +import { getAppConfig } from '../../../config'; +import { IsBase64 } from '../../../validation/decorators/isBase64'; +import { UploadOptionsDto } from './UploadOptionsDto'; + +const { + validation: { + upload: { maxCiphertextBytes } + } +} = getAppConfig(); + +export class UploadCiphertextOptionsDto extends UploadOptionsDto { + @IsNotEmpty() + @IsBase64({ byteLength: 12 }) + iv: string; + + @IsNotEmpty() + @IsBase64({ maxByteLength: maxCiphertextBytes }) + ciphertext: string; +} diff --git a/backend/src/modules/upload/dto/UploadBatchOptionsDto.ts b/backend/src/modules/upload/dto/UploadOptionsDto.ts similarity index 63% rename from backend/src/modules/upload/dto/UploadBatchOptionsDto.ts rename to backend/src/modules/upload/dto/UploadOptionsDto.ts index 4070d34..764adbd 100644 --- a/backend/src/modules/upload/dto/UploadBatchOptionsDto.ts +++ b/backend/src/modules/upload/dto/UploadOptionsDto.ts @@ -2,13 +2,15 @@ import { getAppConfig } from '../../../config'; import { NullOrDate, NullOrInt } from '../../../validation/decorators/nullOr'; const { - validation: { uploadMaxViewsMax } + validation: { + upload: { maxViewsMax } + } } = getAppConfig(); -export class UploadBatchOptionsDto { +export class UploadOptionsDto { @NullOrDate() expiresAt: Date | null; - @NullOrInt({ min: 1, max: uploadMaxViewsMax }) + @NullOrInt({ min: 1, max: maxViewsMax }) maxViews: number | null; } diff --git a/backend/src/modules/upload/entities/Upload.ts b/backend/src/modules/upload/entities/Upload.ts index 3d45923..45807e1 100644 --- a/backend/src/modules/upload/entities/Upload.ts +++ b/backend/src/modules/upload/entities/Upload.ts @@ -26,10 +26,10 @@ export class Upload { @Column() deleteTokenHash: string; - @OneToOne(() => UploadZk, zk => zk.upload) + @OneToOne(() => UploadZk, zk => zk.upload, { cascade: true }) zk: UploadZk | null; - @OneToOne(() => UploadHotlink, hotlink => hotlink.upload) + @OneToOne(() => UploadHotlink, hotlink => hotlink.upload, { cascade: true }) hotlink: UploadHotlink | null; @Column({ type: 'timestamptz', nullable: true }) diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts new file mode 100644 index 0000000..b52d2c9 --- /dev/null +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -0,0 +1,202 @@ +import { BadRequestException } from '@nestjs/common'; +import { createHash } from 'node:crypto'; +import type { DataSource, EntityManager } from 'typeorm'; +import { fileTypeFromBuffer } from 'file-type'; +import { StorageService } from '../../storage/services/StorageService'; +import { UploadMode } from '../types/UploadMode'; + +jest.mock('@nestjs/typeorm', () => ({ + InjectDataSource: () => (): void => undefined +})); + +jest.mock('file-type', () => ({ + fileTypeFromBuffer: jest.fn() +})); + +const fixedId = '11111111-1111-4111-8111-111111111111'; +const fixedDeleteTokenBytes = Buffer.alloc(32, 0xab); + +jest.mock('node:crypto', () => { + const actual = jest.requireActual('node:crypto'); + + return { + ...actual, + randomUUID: jest.fn(() => fixedId), + randomBytes: jest.fn((size: number) => (size === 32 ? fixedDeleteTokenBytes : Buffer.alloc(size))) + }; +}); + +import { UploadService } from './UploadService'; + +const mockFileTypeFromBuffer = fileTypeFromBuffer as jest.MockedFunction; + +const ivBase64 = (): string => Buffer.alloc(12, 0x11).toString('base64'); + +const ciphertextBase64 = (fill = 0xcd): string => Buffer.alloc(64, fill).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'); + +describe('UploadService', () => { + let service: UploadService; + let dataSource: jest.Mocked>; + let storageService: jest.Mocked>; + let uploadRepository: { create: jest.Mock; save: jest.Mock }; + let transaction: jest.Mock; + + beforeEach(() => { + uploadRepository = { + create: jest.fn((entityLike: unknown) => entityLike), + save: jest.fn(async (entity: unknown) => entity) + }; + + transaction = jest.fn(async (runInTransaction: (manager: EntityManager) => Promise) => { + const manager = { + getRepository: jest.fn(() => uploadRepository) + } as unknown as EntityManager; + + return runInTransaction(manager); + }); + + dataSource = { transaction }; + storageService = { write: jest.fn().mockResolvedValue(undefined) }; + + mockFileTypeFromBuffer.mockResolvedValue(undefined); + + service = new UploadService( + dataSource as unknown as DataSource, + storageService as unknown as StorageService + ); + }); + + describe('createHotlinkUpload', () => { + it('persists upload with hotlink relation and writes ciphertext', async () => { + const ciphertext = ciphertextBase64(); + const ciphertextBuffer = Buffer.from(ciphertext, 'base64'); + const iv = ivBase64(); + const deleteToken = fixedDeleteTokenBytes.toString('base64url'); + + const result = await service.createHotlinkUpload({ + iv, + ciphertext, + expiresAt: null, + maxViews: null, + mimeType: 'image/png' + }); + + expect(result).toEqual({ + id: fixedId, + mode: UploadMode.Hotlink, + deleteToken + }); + + expect(uploadRepository.create).toHaveBeenCalledWith({ + id: fixedId, + mode: UploadMode.Hotlink, + storagePath: fixedId, + iv: Buffer.from(iv, 'base64'), + maxViews: null, + deleteTokenHash: hashDeleteToken(deleteToken), + expiresAt: null, + hotlink: { mimeType: 'image/png' } + }); + expect(uploadRepository.save).toHaveBeenCalledTimes(1); + expect(storageService.write).toHaveBeenCalledWith(fixedId, ciphertextBuffer); + expect(transaction).toHaveBeenCalledTimes(1); + }); + }); + + describe('createZkUpload', () => { + it('persists zk upload without password wrap fields when not password protected', async () => { + const ciphertext = ciphertextBase64(); + + await service.createZkUpload({ + iv: ivBase64(), + ciphertext, + expiresAt: null, + maxViews: 5, + passwordProtected: false + }); + + expect(uploadRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + mode: UploadMode.Zk, + maxViews: 5, + zk: { + passwordProtected: false, + wrappedDek: null, + wrapIv: null, + salt: null + } + }) + ); + expect(storageService.write).toHaveBeenCalledWith(fixedId, Buffer.from(ciphertext, 'base64')); + }); + + it('decodes password wrap fields when password protected', async () => { + const wrappedDek = wrapFieldBase64(32); + const wrapIv = ivBase64(); + const salt = wrapFieldBase64(16); + + await service.createZkUpload({ + iv: ivBase64(), + ciphertext: ciphertextBase64(), + expiresAt: null, + maxViews: null, + passwordProtected: true, + wrappedDek, + wrapIv, + salt + }); + + expect(uploadRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + zk: { + passwordProtected: true, + wrappedDek: Buffer.from(wrappedDek, 'base64'), + wrapIv: Buffer.from(wrapIv, 'base64'), + salt: Buffer.from(salt, 'base64') + } + }) + ); + }); + }); + + describe('throwIfUnencryptedImage', () => { + it('rejects ciphertext that sniffs as an image before persisting', async () => { + mockFileTypeFromBuffer.mockResolvedValue({ ext: 'jpg', mime: 'image/jpeg' }); + + await expect( + service.createHotlinkUpload({ + iv: ivBase64(), + ciphertext: ciphertextBase64(0xff), + expiresAt: null, + maxViews: null, + mimeType: 'image/jpeg' + }) + ).rejects.toThrow(BadRequestException); + + expect(transaction).not.toHaveBeenCalled(); + expect(storageService.write).not.toHaveBeenCalled(); + }); + }); + + describe('transaction rollback on storage failure', () => { + it('propagates storage write errors from the transaction', async () => { + storageService.write.mockRejectedValue(new Error('disk full')); + + await expect( + service.createHotlinkUpload({ + iv: ivBase64(), + ciphertext: ciphertextBase64(), + expiresAt: null, + maxViews: null, + mimeType: 'image/png' + }) + ).rejects.toThrow('disk full'); + + expect(uploadRepository.save).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts new file mode 100644 index 0000000..dfbe41d --- /dev/null +++ b/backend/src/modules/upload/services/UploadService.ts @@ -0,0 +1,123 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { fileTypeFromBuffer } from 'file-type'; +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { DataSource } from 'typeorm'; +import { fromBase64 } from '../../../utils/fromBase64'; +import { StorageService } from '../../storage/services/StorageService'; +import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto'; +import { CreateZkUploadDto } from '../dto/CreateZkUploadDto'; +import { Upload } from '../entities/Upload'; +import { CreatedUploadResult } from '../types/CreatedUploadResult'; +import { UploadMode } from '../types/UploadMode'; + +@Injectable() +export class UploadService { + constructor( + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly storageService: StorageService + ) {} + + async createZkUpload({ + iv, + ciphertext, + expiresAt, + maxViews, + passwordProtected, + wrappedDek, + wrapIv, + salt + }: CreateZkUploadDto): Promise { + const ciphertextBuffer = fromBase64(ciphertext); + + await this.throwIfUnencryptedImage(ciphertextBuffer); + + const deleteToken = randomBytes(32).toString('base64url'); + const id = randomUUID(); + const storagePath = id; + + await this.dataSource.transaction(async manager => { + const uploadRepository = manager.getRepository(Upload); + + const upload = uploadRepository.create({ + id, + mode: UploadMode.Zk, + storagePath, + iv: fromBase64(iv), + maxViews, + deleteTokenHash: this.hashDeleteToken(deleteToken), + expiresAt, + zk: { + passwordProtected, + wrappedDek: passwordProtected && wrappedDek ? fromBase64(wrappedDek) : null, + wrapIv: passwordProtected && wrapIv ? fromBase64(wrapIv) : null, + salt: passwordProtected && salt ? fromBase64(salt) : null + } + }); + + await uploadRepository.save(upload); + + await this.storageService.write(storagePath, ciphertextBuffer); + }); + + return { + id, + mode: UploadMode.Zk, + deleteToken + }; + } + + async createHotlinkUpload({ + iv, + ciphertext, + expiresAt, + maxViews, + mimeType + }: CreateHotlinkUploadDto): Promise { + const ciphertextBuffer = fromBase64(ciphertext); + + await this.throwIfUnencryptedImage(ciphertextBuffer); + + const deleteToken = randomBytes(32).toString('base64url'); + const id = randomUUID(); + const storagePath = id; + + await this.dataSource.transaction(async manager => { + const uploadRepository = manager.getRepository(Upload); + + const upload = uploadRepository.create({ + id, + mode: UploadMode.Hotlink, + storagePath, + iv: fromBase64(iv), + maxViews, + deleteTokenHash: this.hashDeleteToken(deleteToken), + expiresAt, + hotlink: { mimeType } + }); + + await uploadRepository.save(upload); + + await this.storageService.write(storagePath, ciphertextBuffer); + }); + + return { + id, + mode: UploadMode.Hotlink, + deleteToken + }; + } + + private hashDeleteToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); + } + + private async throwIfUnencryptedImage(ciphertext: Buffer): Promise { + const detected = await fileTypeFromBuffer(ciphertext); + + if (detected?.mime.startsWith('image/')) { + throw new BadRequestException('Ciphertext must not be a plaintext image'); + } + } +} diff --git a/backend/src/modules/upload/types/CreatedUploadResult.ts b/backend/src/modules/upload/types/CreatedUploadResult.ts new file mode 100644 index 0000000..7cf4cef --- /dev/null +++ b/backend/src/modules/upload/types/CreatedUploadResult.ts @@ -0,0 +1,7 @@ +import { UploadMode } from './UploadMode'; + +export type CreatedUploadResult = { + id: string; + mode: UploadMode; + deleteToken: string; +}; diff --git a/backend/src/utils/fromBase64.ts b/backend/src/utils/fromBase64.ts new file mode 100644 index 0000000..d0fd5e9 --- /dev/null +++ b/backend/src/utils/fromBase64.ts @@ -0,0 +1 @@ +export const fromBase64 = (value: string): Buffer => Buffer.from(value, 'base64');