diff --git a/backend/src/modules/upload/controllers/UploadController.ts b/backend/src/modules/upload/controllers/UploadController.ts index 04d6754..b6fae3c 100644 --- a/backend/src/modules/upload/controllers/UploadController.ts +++ b/backend/src/modules/upload/controllers/UploadController.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { throttleProfiles } from '../../../config/throttleProfiles'; import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto'; @@ -20,4 +20,9 @@ export class UploadController { createHotlink(@Body() dto: CreateHotlinkUploadDto) { return this.uploadService.createHotlinkUpload(dto); } + + @Get(':id') + getZkCiphertext(@Param('id', ParseUUIDPipe) id: string) { + return this.uploadService.getZkUploadCiphertext(id); + } } diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts index b52d2c9..0087ea6 100644 --- a/backend/src/modules/upload/services/UploadService.spec.ts +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -1,12 +1,14 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { createHash } from 'node:crypto'; -import type { DataSource, EntityManager } from 'typeorm'; +import type { DataSource, EntityManager, Repository } from 'typeorm'; +import type { Upload } from '../entities/Upload'; import { fileTypeFromBuffer } from 'file-type'; import { StorageService } from '../../storage/services/StorageService'; import { UploadMode } from '../types/UploadMode'; jest.mock('@nestjs/typeorm', () => ({ - InjectDataSource: () => (): void => undefined + InjectDataSource: () => (): void => undefined, + InjectRepository: () => (): void => undefined })); jest.mock('file-type', () => ({ @@ -41,14 +43,15 @@ const hashDeleteToken = (token: string): string => createHash('sha256').update(t describe('UploadService', () => { let service: UploadService; let dataSource: jest.Mocked>; - let storageService: jest.Mocked>; - let uploadRepository: { create: jest.Mock; save: jest.Mock }; + let storageService: jest.Mocked>; + let uploadRepository: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock }; let transaction: jest.Mock; beforeEach(() => { uploadRepository = { create: jest.fn((entityLike: unknown) => entityLike), - save: jest.fn(async (entity: unknown) => entity) + save: jest.fn(async (entity: unknown) => entity), + findOne: jest.fn() }; transaction = jest.fn(async (runInTransaction: (manager: EntityManager) => Promise) => { @@ -60,13 +63,17 @@ describe('UploadService', () => { }); dataSource = { transaction }; - storageService = { write: jest.fn().mockResolvedValue(undefined) }; + storageService = { + write: jest.fn().mockResolvedValue(undefined), + read: jest.fn() + }; mockFileTypeFromBuffer.mockResolvedValue(undefined); service = new UploadService( dataSource as unknown as DataSource, - storageService as unknown as StorageService + storageService as unknown as StorageService, + uploadRepository as unknown as Repository ); }); @@ -182,6 +189,100 @@ describe('UploadService', () => { }); }); + describe('getZkUploadCiphertext', () => { + it('returns iv ciphertext and password flags for zk upload', async () => { + const iv = Buffer.alloc(12, 0x11); + const ciphertext = Buffer.alloc(64, 0xcd); + + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv, + zk: { + passwordProtected: false, + wrappedDek: null, + wrapIv: null, + salt: null + } + }); + storageService.read.mockResolvedValue(ciphertext); + + await expect(service.getZkUploadCiphertext(fixedId)).resolves.toEqual({ + iv: iv.toString('base64'), + ciphertext: ciphertext.toString('base64'), + passwordProtected: false + }); + + expect(uploadRepository.findOne).toHaveBeenCalledWith({ + where: { id: fixedId, mode: UploadMode.Zk }, + relations: ['zk'] + }); + expect(storageService.read).toHaveBeenCalledWith(fixedId); + }); + + it('includes wrap fields when password protected', async () => { + const iv = Buffer.alloc(12, 0x11); + const ciphertext = Buffer.alloc(8, 0xcd); + const wrappedDek = Buffer.alloc(32, 0x22); + const wrapIv = Buffer.alloc(12, 0x33); + const salt = Buffer.alloc(16, 0x44); + + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv, + zk: { + passwordProtected: true, + wrappedDek, + wrapIv, + salt + } + }); + storageService.read.mockResolvedValue(ciphertext); + + await expect(service.getZkUploadCiphertext(fixedId)).resolves.toEqual({ + iv: iv.toString('base64'), + ciphertext: ciphertext.toString('base64'), + passwordProtected: true, + wrappedDek: wrappedDek.toString('base64'), + wrapIv: wrapIv.toString('base64'), + salt: salt.toString('base64') + }); + }); + + it('throws NotFoundException when upload is missing or not zk', async () => { + uploadRepository.findOne.mockResolvedValue(null); + + await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); + expect(storageService.read).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when ciphertext file is missing', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv: Buffer.alloc(12), + zk: { passwordProtected: false, wrappedDek: null, wrapIv: null, salt: null } + }); + storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when password protected but wrap data is incomplete', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv: Buffer.alloc(12), + zk: { + passwordProtected: true, + wrappedDek: null, + wrapIv: null, + salt: null + } + }); + storageService.read.mockResolvedValue(Buffer.alloc(8)); + + await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); + }); + }); + describe('transaction rollback on storage failure', () => { it('propagates storage write errors from the transaction', async () => { storageService.write.mockRejectedValue(new Error('disk full')); diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index dfbe41d..d5500af 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -1,22 +1,27 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { fileTypeFromBuffer } from 'file-type'; import { createHash, randomBytes, randomUUID } from 'node:crypto'; -import { DataSource } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; import { fromBase64 } from '../../../utils/fromBase64'; +import { isErrorEnoent } from '../../../utils/isErrorEnoent'; +import { toBase64 } from '../../../utils/toBase64'; 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'; +import { GetZkUploadCiphertextResult } from '../types/GetZkUploadCiphertextResult'; @Injectable() export class UploadService { constructor( @InjectDataSource() private readonly dataSource: DataSource, - private readonly storageService: StorageService + private readonly storageService: StorageService, + @InjectRepository(Upload) + private readonly uploadRepository: Repository ) {} async createZkUpload({ @@ -109,6 +114,47 @@ export class UploadService { }; } + async getZkUploadCiphertext(id: string): Promise { + const upload = await this.uploadRepository.findOne({ + where: { id, mode: UploadMode.Zk }, + relations: ['zk'] + }); + + if (!upload?.zk) { + throw new NotFoundException(); + } + + let ciphertextBuffer: Buffer; + + try { + ciphertextBuffer = await this.storageService.read(upload.storagePath); + } catch (error: unknown) { + if (isErrorEnoent(error)) { + throw new NotFoundException(); + } + + throw error; + } + + const result: GetZkUploadCiphertextResult = { + iv: toBase64(upload.iv), + ciphertext: toBase64(ciphertextBuffer), + passwordProtected: upload.zk.passwordProtected + }; + + if (upload.zk.passwordProtected) { + if (!upload.zk.wrappedDek || !upload.zk.wrapIv || !upload.zk.salt) { + throw new NotFoundException(); + } + + result.wrappedDek = toBase64(upload.zk.wrappedDek); + result.wrapIv = toBase64(upload.zk.wrapIv); + result.salt = toBase64(upload.zk.salt); + } + + return result; + } + private hashDeleteToken(token: string): string { return createHash('sha256').update(token, 'utf8').digest('hex'); } diff --git a/backend/src/modules/upload/types/GetZkUploadCiphertextResult.ts b/backend/src/modules/upload/types/GetZkUploadCiphertextResult.ts new file mode 100644 index 0000000..e26d889 --- /dev/null +++ b/backend/src/modules/upload/types/GetZkUploadCiphertextResult.ts @@ -0,0 +1,8 @@ +export type GetZkUploadCiphertextResult = { + iv: string; + ciphertext: string; + passwordProtected: boolean; + wrappedDek?: string; + wrapIv?: string; + salt?: string; +}; diff --git a/backend/src/utils/isErrorEnoent.spec.ts b/backend/src/utils/isErrorEnoent.spec.ts new file mode 100644 index 0000000..f2b8dcb --- /dev/null +++ b/backend/src/utils/isErrorEnoent.spec.ts @@ -0,0 +1,27 @@ +import { readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { isErrorEnoent } from './isErrorEnoent'; + +describe('isErrorEnoent', () => { + it('returns true for real fs ENOENT errors', async () => { + const missingPath = join(tmpdir(), `private-image-enoent-${process.pid}`); + + try { + await readFile(missingPath); + throw new Error('expected readFile to throw'); + } catch (error) { + expect(isErrorEnoent(error)).toBe(true); + } + }); + + it('returns false for other errno codes', () => { + expect(isErrorEnoent(Object.assign(new Error('EACCES'), { code: 'EACCES' }))).toBe(false); + }); + + it('returns false for non-objects and errors without code', () => { + expect(isErrorEnoent(null)).toBe(false); + expect(isErrorEnoent('ENOENT')).toBe(false); + expect(isErrorEnoent(new Error('ENOENT'))).toBe(false); + }); +}); diff --git a/backend/src/utils/isErrorEnoent.ts b/backend/src/utils/isErrorEnoent.ts new file mode 100644 index 0000000..ea46b07 --- /dev/null +++ b/backend/src/utils/isErrorEnoent.ts @@ -0,0 +1,9 @@ +export const isErrorEnoent = (error: unknown): boolean => { + if (typeof error !== 'object' || error === null || !('code' in error)) { + return false; + } + + const { code } = error; + + return typeof code === 'string' && code === 'ENOENT'; +};