Slave/zk viewer #9

Merged
nobswebdev merged 4 commits from slave/zk-viewer into master 2026-10-26 19:24:06 +00:00
6 changed files with 209 additions and 13 deletions
Showing only changes of commit 65bbc4537b - Show all commits
@@ -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);
}
}
@@ -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<Pick<DataSource, 'transaction'>>;
let storageService: jest.Mocked<Pick<StorageService, 'write'>>;
let uploadRepository: { create: jest.Mock; save: jest.Mock };
let storageService: jest.Mocked<Pick<StorageService, 'write' | 'read'>>;
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<unknown>) => {
@@ -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<Upload>
);
});
@@ -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'));
@@ -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<Upload>
) {}
async createZkUpload({
@@ -109,6 +114,47 @@ export class UploadService {
};
}
async getZkUploadCiphertext(id: string): Promise<GetZkUploadCiphertextResult> {
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');
}
@@ -0,0 +1,8 @@
export type GetZkUploadCiphertextResult = {
iv: string;
ciphertext: string;
passwordProtected: boolean;
wrappedDek?: string;
wrapIv?: string;
salt?: string;
};
+27
View File
@@ -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);
});
});
+9
View File
@@ -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';
};