From 65bbc4537b0b9be5e76a15f8df2f9f55f25fbb36 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 26 Oct 2026 02:11:57 +0100 Subject: [PATCH 1/4] add GET /api/uploads/:id for zk ciphertext JSON Expose ZK ciphertext and wrap fields for the viewer; return 404 for missing, non-zk, or absent storage. Use injected upload repository and shared isErrorEnoent for missing files. --- .../upload/controllers/UploadController.ts | 7 +- .../upload/services/UploadService.spec.ts | 117 ++++++++++++++++-- .../modules/upload/services/UploadService.ts | 54 +++++++- .../types/GetZkUploadCiphertextResult.ts | 8 ++ backend/src/utils/isErrorEnoent.spec.ts | 27 ++++ backend/src/utils/isErrorEnoent.ts | 9 ++ 6 files changed, 209 insertions(+), 13 deletions(-) create mode 100644 backend/src/modules/upload/types/GetZkUploadCiphertextResult.ts create mode 100644 backend/src/utils/isErrorEnoent.spec.ts create mode 100644 backend/src/utils/isErrorEnoent.ts 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'; +}; -- 2.54.0 From 112aa99f1ed9d390e6d6b37ca8ab3223e683b3e1 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 26 Oct 2026 19:13:15 +0100 Subject: [PATCH 2/4] add ViewerView decrypt from URL fragment via view store --- frontend/src/stores/view.ts | 48 +++++++++++++++++ .../upload/GetZkUploadCiphertextResult.ts | 8 +++ frontend/src/views/ViewerView.vue | 54 +++++++++++++++++-- 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 frontend/src/stores/view.ts create mode 100644 frontend/src/types/upload/GetZkUploadCiphertextResult.ts diff --git a/frontend/src/stores/view.ts b/frontend/src/stores/view.ts new file mode 100644 index 0000000..476d07c --- /dev/null +++ b/frontend/src/stores/view.ts @@ -0,0 +1,48 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { api } from '@/plugins/axios'; +import type { GetZkUploadCiphertextResult } from '@/types/upload/GetZkUploadCiphertextResult'; +import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; +import { toArrayBuffer } from '@/utils/toArrayBuffer'; +import { decryptZkUpload } from '@/utils/upload/decryptZkUpload'; + +export const useViewStore = defineStore('view', () => { + const imageObjectUrl = ref(null); + + const clearImage = (): void => { + if (imageObjectUrl.value) { + URL.revokeObjectURL(imageObjectUrl.value); + imageObjectUrl.value = null; + } + }; + + const fetchZkCiphertext = async (uploadId: string): Promise => { + const { data } = await api.get(`/uploads/${uploadId}`); + + return data; + }; + + const loadZk = async (payload: GetZkUploadCiphertextResult, fragmentKey: string): Promise => { + clearImage(); + + const iv = base64ToBytes(payload.iv); + const ciphertext = base64ToBytes(payload.ciphertext); + + const plaintext = await decryptZkUpload(ciphertext, iv, fragmentKey); + + const buffer = toArrayBuffer(plaintext); + + if (!buffer) { + throw new Error('Could not build image blob'); + } + + imageObjectUrl.value = URL.createObjectURL(new Blob([buffer])); + }; + + return { + imageObjectUrl, + fetchZkCiphertext, + loadZk, + clearImage + }; +}); diff --git a/frontend/src/types/upload/GetZkUploadCiphertextResult.ts b/frontend/src/types/upload/GetZkUploadCiphertextResult.ts new file mode 100644 index 0000000..e26d889 --- /dev/null +++ b/frontend/src/types/upload/GetZkUploadCiphertextResult.ts @@ -0,0 +1,8 @@ +export type GetZkUploadCiphertextResult = { + iv: string; + ciphertext: string; + passwordProtected: boolean; + wrappedDek?: string; + wrapIv?: string; + salt?: string; +}; diff --git a/frontend/src/views/ViewerView.vue b/frontend/src/views/ViewerView.vue index 0d1b36c..440248f 100644 --- a/frontend/src/views/ViewerView.vue +++ b/frontend/src/views/ViewerView.vue @@ -1,6 +1,54 @@ + + -- 2.54.0 From dc8e576679cd893f7c645a1de25f0fadbc0d66c0 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 26 Oct 2026 20:06:54 +0100 Subject: [PATCH 3/4] add password prompt to ZK viewer for wrapped uploads --- frontend/src/stores/view.ts | 35 ++++++++++++++++- frontend/src/views/ViewerView.vue | 62 +++++++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/frontend/src/stores/view.ts b/frontend/src/stores/view.ts index 476d07c..bef8188 100644 --- a/frontend/src/stores/view.ts +++ b/frontend/src/stores/view.ts @@ -4,6 +4,7 @@ import { api } from '@/plugins/axios'; import type { GetZkUploadCiphertextResult } from '@/types/upload/GetZkUploadCiphertextResult'; import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; import { toArrayBuffer } from '@/utils/toArrayBuffer'; +import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload'; import { decryptZkUpload } from '@/utils/upload/decryptZkUpload'; export const useViewStore = defineStore('view', () => { @@ -22,13 +23,43 @@ export const useViewStore = defineStore('view', () => { return data; }; - const loadZk = async (payload: GetZkUploadCiphertextResult, fragmentKey: string): Promise => { + const loadZk = async ( + payload: GetZkUploadCiphertextResult, + fragmentKey: string, + password?: string + ): Promise => { clearImage(); const iv = base64ToBytes(payload.iv); const ciphertext = base64ToBytes(payload.ciphertext); - const plaintext = await decryptZkUpload(ciphertext, iv, fragmentKey); + let plaintext: Uint8Array; + + if (payload.passwordProtected) { + if (!password) { + throw new Error('Password required'); + } + + if (!payload.wrappedDek || !payload.wrapIv || !payload.salt) { + throw new Error('Incomplete password protection data'); + } + + const wrappedDek = base64ToBytes(payload.wrappedDek); + const wrapIv = base64ToBytes(payload.wrapIv); + const salt = base64ToBytes(payload.salt); + + plaintext = await decryptPasswordProtectedZkUpload( + ciphertext, + iv, + fragmentKey, + password, + wrappedDek, + wrapIv, + salt + ); + } else { + plaintext = await decryptZkUpload(ciphertext, iv, fragmentKey); + } const buffer = toArrayBuffer(plaintext); diff --git a/frontend/src/views/ViewerView.vue b/frontend/src/views/ViewerView.vue index 440248f..c60e38e 100644 --- a/frontend/src/views/ViewerView.vue +++ b/frontend/src/views/ViewerView.vue @@ -2,6 +2,29 @@

Loading image...

+
+ + +

{{ unlockError }}

+ + +
+
@@ -11,12 +34,18 @@ import { storeToRefs } from 'pinia'; import { onBeforeMount, onBeforeUnmount, ref } from 'vue'; import { useRoute } from 'vue-router'; import { useViewStore } from '@/stores/view'; +import type { GetZkUploadCiphertextResult } from '@/types/upload/GetZkUploadCiphertextResult'; const route = useRoute(); const viewStore = useViewStore(); const { imageObjectUrl } = storeToRefs(viewStore); const fetching = ref(false); +const unlocking = ref(false); +const zkPayload = ref(null); +const fragmentKey = ref(''); +const password = ref(''); +const unlockError = ref(''); onBeforeMount(async () => { const uploadId = route.params.id; @@ -25,22 +54,24 @@ onBeforeMount(async () => { return; } - const fragmentKey = route.hash.slice(1); + const key = route.hash.slice(1); - if (!fragmentKey) { + if (!key) { return; } + fragmentKey.value = key; fetching.value = true; try { const payload = await viewStore.fetchZkCiphertext(uploadId); if (payload.passwordProtected) { + zkPayload.value = payload; return; } - await viewStore.loadZk(payload, fragmentKey); + await viewStore.loadZk(payload, key); } catch (error) { console.error('[viewer] load failed', { uploadId, error }); } finally { @@ -48,6 +79,31 @@ onBeforeMount(async () => { } }); +const onUnlockSubmit = async (): Promise => { + const payload = zkPayload.value; + const key = fragmentKey.value; + + if (!payload || !key) { + return; + } + + unlocking.value = true; + unlockError.value = ''; + + try { + await viewStore.loadZk(payload, key, password.value); + + zkPayload.value = null; + password.value = ''; + } catch (error) { + console.error('[viewer] unlock failed', { error }); + + unlockError.value = 'Wrong password'; + } finally { + unlocking.value = false; + } +}; + onBeforeUnmount(() => { viewStore.clearImage(); }); -- 2.54.0 From 82cdb6ffa39e990f0aa509f4a577042b395a91a8 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 26 Oct 2026 20:22:47 +0100 Subject: [PATCH 4/4] Clear ZK viewer secrets and image blob when leaving the page. --- frontend/src/views/ViewerView.vue | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/ViewerView.vue b/frontend/src/views/ViewerView.vue index c60e38e..f46f595 100644 --- a/frontend/src/views/ViewerView.vue +++ b/frontend/src/views/ViewerView.vue @@ -79,6 +79,11 @@ onBeforeMount(async () => { } }); +onBeforeUnmount(() => { + cleanRefs(); + viewStore.clearImage(); +}); + const onUnlockSubmit = async (): Promise => { const payload = zkPayload.value; const key = fragmentKey.value; @@ -104,7 +109,10 @@ const onUnlockSubmit = async (): Promise => { } }; -onBeforeUnmount(() => { - viewStore.clearImage(); -}); +const cleanRefs = (): void => { + zkPayload.value = null; + fragmentKey.value = ''; + password.value = ''; + unlockError.value = ''; +}; -- 2.54.0