Slave/zk viewer #9
@@ -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 { Throttle } from '@nestjs/throttler';
|
||||||
import { throttleProfiles } from '../../../config/throttleProfiles';
|
import { throttleProfiles } from '../../../config/throttleProfiles';
|
||||||
import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto';
|
import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto';
|
||||||
@@ -20,4 +20,9 @@ export class UploadController {
|
|||||||
createHotlink(@Body() dto: CreateHotlinkUploadDto) {
|
createHotlink(@Body() dto: CreateHotlinkUploadDto) {
|
||||||
return this.uploadService.createHotlinkUpload(dto);
|
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 { 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 { fileTypeFromBuffer } from 'file-type';
|
||||||
import { StorageService } from '../../storage/services/StorageService';
|
import { StorageService } from '../../storage/services/StorageService';
|
||||||
import { UploadMode } from '../types/UploadMode';
|
import { UploadMode } from '../types/UploadMode';
|
||||||
|
|
||||||
jest.mock('@nestjs/typeorm', () => ({
|
jest.mock('@nestjs/typeorm', () => ({
|
||||||
InjectDataSource: () => (): void => undefined
|
InjectDataSource: () => (): void => undefined,
|
||||||
|
InjectRepository: () => (): void => undefined
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('file-type', () => ({
|
jest.mock('file-type', () => ({
|
||||||
@@ -41,14 +43,15 @@ const hashDeleteToken = (token: string): string => createHash('sha256').update(t
|
|||||||
describe('UploadService', () => {
|
describe('UploadService', () => {
|
||||||
let service: UploadService;
|
let service: UploadService;
|
||||||
let dataSource: jest.Mocked<Pick<DataSource, 'transaction'>>;
|
let dataSource: jest.Mocked<Pick<DataSource, 'transaction'>>;
|
||||||
let storageService: jest.Mocked<Pick<StorageService, 'write'>>;
|
let storageService: jest.Mocked<Pick<StorageService, 'write' | 'read'>>;
|
||||||
let uploadRepository: { create: jest.Mock; save: jest.Mock };
|
let uploadRepository: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock };
|
||||||
let transaction: jest.Mock;
|
let transaction: jest.Mock;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
uploadRepository = {
|
uploadRepository = {
|
||||||
create: jest.fn((entityLike: unknown) => entityLike),
|
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>) => {
|
transaction = jest.fn(async (runInTransaction: (manager: EntityManager) => Promise<unknown>) => {
|
||||||
@@ -60,13 +63,17 @@ describe('UploadService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
dataSource = { transaction };
|
dataSource = { transaction };
|
||||||
storageService = { write: jest.fn().mockResolvedValue(undefined) };
|
storageService = {
|
||||||
|
write: jest.fn().mockResolvedValue(undefined),
|
||||||
|
read: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
mockFileTypeFromBuffer.mockResolvedValue(undefined);
|
mockFileTypeFromBuffer.mockResolvedValue(undefined);
|
||||||
|
|
||||||
service = new UploadService(
|
service = new UploadService(
|
||||||
dataSource as unknown as DataSource,
|
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', () => {
|
describe('transaction rollback on storage failure', () => {
|
||||||
it('propagates storage write errors from the transaction', async () => {
|
it('propagates storage write errors from the transaction', async () => {
|
||||||
storageService.write.mockRejectedValue(new Error('disk full'));
|
storageService.write.mockRejectedValue(new Error('disk full'));
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||||
import { fileTypeFromBuffer } from 'file-type';
|
import { fileTypeFromBuffer } from 'file-type';
|
||||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { fromBase64 } from '../../../utils/fromBase64';
|
import { fromBase64 } from '../../../utils/fromBase64';
|
||||||
|
import { isErrorEnoent } from '../../../utils/isErrorEnoent';
|
||||||
|
import { toBase64 } from '../../../utils/toBase64';
|
||||||
import { StorageService } from '../../storage/services/StorageService';
|
import { StorageService } from '../../storage/services/StorageService';
|
||||||
import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto';
|
import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto';
|
||||||
import { CreateZkUploadDto } from '../dto/CreateZkUploadDto';
|
import { CreateZkUploadDto } from '../dto/CreateZkUploadDto';
|
||||||
import { Upload } from '../entities/Upload';
|
import { Upload } from '../entities/Upload';
|
||||||
import { CreatedUploadResult } from '../types/CreatedUploadResult';
|
import { CreatedUploadResult } from '../types/CreatedUploadResult';
|
||||||
import { UploadMode } from '../types/UploadMode';
|
import { UploadMode } from '../types/UploadMode';
|
||||||
|
import { GetZkUploadCiphertextResult } from '../types/GetZkUploadCiphertextResult';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UploadService {
|
export class UploadService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectDataSource()
|
@InjectDataSource()
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly storageService: StorageService
|
private readonly storageService: StorageService,
|
||||||
|
@InjectRepository(Upload)
|
||||||
|
private readonly uploadRepository: Repository<Upload>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createZkUpload({
|
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 {
|
private hashDeleteToken(token: string): string {
|
||||||
return createHash('sha256').update(token, 'utf8').digest('hex');
|
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;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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 { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload';
|
||||||
|
import { decryptZkUpload } from '@/utils/upload/decryptZkUpload';
|
||||||
|
|
||||||
|
export const useViewStore = defineStore('view', () => {
|
||||||
|
const imageObjectUrl = ref<string | null>(null);
|
||||||
|
|
||||||
|
const clearImage = (): void => {
|
||||||
|
if (imageObjectUrl.value) {
|
||||||
|
URL.revokeObjectURL(imageObjectUrl.value);
|
||||||
|
imageObjectUrl.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchZkCiphertext = async (uploadId: string): Promise<GetZkUploadCiphertextResult> => {
|
||||||
|
const { data } = await api.get<GetZkUploadCiphertextResult>(`/uploads/${uploadId}`);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadZk = async (
|
||||||
|
payload: GetZkUploadCiphertextResult,
|
||||||
|
fragmentKey: string,
|
||||||
|
password?: string
|
||||||
|
): Promise<void> => {
|
||||||
|
clearImage();
|
||||||
|
|
||||||
|
const iv = base64ToBytes(payload.iv);
|
||||||
|
const ciphertext = base64ToBytes(payload.ciphertext);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (!buffer) {
|
||||||
|
throw new Error('Could not build image blob');
|
||||||
|
}
|
||||||
|
|
||||||
|
imageObjectUrl.value = URL.createObjectURL(new Blob([buffer]));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageObjectUrl,
|
||||||
|
fetchZkCiphertext,
|
||||||
|
loadZk,
|
||||||
|
clearImage
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type GetZkUploadCiphertextResult = {
|
||||||
|
iv: string;
|
||||||
|
ciphertext: string;
|
||||||
|
passwordProtected: boolean;
|
||||||
|
wrappedDek?: string;
|
||||||
|
wrapIv?: string;
|
||||||
|
salt?: string;
|
||||||
|
};
|
||||||
@@ -1,6 +1,118 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="flex min-h-screen flex-col p-8">
|
<main class="flex min-h-screen items-center justify-center p-4">
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Viewer</h1>
|
<p v-if="fetching" class="text-sm text-muted-foreground">Loading image...</p>
|
||||||
<p class="mt-2 text-muted-foreground">Zero-knowledge viewer — coming soon.</p>
|
|
||||||
|
<form v-else-if="zkPayload" class="flex w-full max-w-sm flex-col gap-3" @submit.prevent="onUnlockSubmit">
|
||||||
|
<label class="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span>Password</span>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
:disabled="unlocking"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-if="unlockError" class="text-sm text-destructive">{{ unlockError }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:disabled="unlocking || !password"
|
||||||
|
>
|
||||||
|
{{ unlocking ? 'Unlocking...' : 'View image' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<img v-else-if="imageObjectUrl" :src="imageObjectUrl" class="max-h-[80vh] max-w-full object-contain" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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<GetZkUploadCiphertextResult | null>(null);
|
||||||
|
const fragmentKey = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const unlockError = ref('');
|
||||||
|
|
||||||
|
onBeforeMount(async () => {
|
||||||
|
const uploadId = route.params.id;
|
||||||
|
|
||||||
|
if (typeof uploadId !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = route.hash.slice(1);
|
||||||
|
|
||||||
|
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, key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[viewer] load failed', { uploadId, error });
|
||||||
|
} finally {
|
||||||
|
fetching.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cleanRefs();
|
||||||
|
viewStore.clearImage();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onUnlockSubmit = async (): Promise<void> => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanRefs = (): void => {
|
||||||
|
zkPayload.value = null;
|
||||||
|
fragmentKey.value = '';
|
||||||
|
password.value = '';
|
||||||
|
unlockError.value = '';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user