Execute batch uploads with metadata gates, encryption, and queue state.

Separate upload entries per file, ALTCHA on POST, and stripped-or-original fallback on metadata errors.
This commit is contained in:
2026-10-23 18:34:13 +02:00
parent fe643d9831
commit 271c2b7b2d
14 changed files with 333 additions and 136 deletions
+49 -59
View File
@@ -2,23 +2,29 @@ import { defineStore, storeToRefs } from 'pinia';
import { ref } from 'vue';
import { useExiftoolStore } from '@/stores/exiftool';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool';
const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool';
const METADATA_LOADING_MESSAGE = 'Loading metadata';
const METADATA_IDLE_MESSAGE = 'Waiting to load metadata...';
const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool...';
const METADATA_PROCESSING_MESSAGE = 'Stripping metadata...';
const METADATA_READY_MESSAGE = 'Metadata stripped';
const METADATA_LOAD_ERROR_MESSAGE = 'Could not load image metadata';
const METADATA_STRIP_ERROR_MESSAGE = 'Could not strip image metadata';
const METADATA_IDLE_MESSAGE = 'Waiting to load metadata';
const METADATA_READY_MESSAGE = 'Metadata stripped';
const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool';
const METADATA_IDLE_PREVIEW: MetadataPreviewEntry = {
status: 'idle',
message: METADATA_IDLE_MESSAGE
};
const METADATA_LOADING_PREVIEW: MetadataPreviewEntry = {
status: 'loading',
message: METADATA_LOADING_MESSAGE
const METADATA_LOAD_ERROR_PREVIEW: MetadataPreviewEntry = {
status: 'error',
message: METADATA_LOAD_ERROR_MESSAGE
};
const EXIFTOOL_PREPARE_ERROR_PREVIEW: MetadataPreviewEntry = {
status: 'error',
message: EXIFTOOL_PREPARE_ERROR_MESSAGE
};
export const useMetadataStore = defineStore('metadata', () => {
@@ -30,7 +36,13 @@ export const useMetadataStore = defineStore('metadata', () => {
const { initStatus: exiftoolInitStatus } = storeToRefs(exiftoolStore);
const { initializeExiftool, readMetadata, stripAllMetadata } = exiftoolStore;
const makeKeyFromFile = (file: File): string => `${file.name}\0${file.size}\0${file.lastModified}`;
const setPreviewEntry = (key: string, entry: MetadataPreviewEntry): void => {
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
};
const setStrippedBlob = (key: string, blob: Blob): void => {
strippedBlobByKey.value = { ...strippedBlobByKey.value, [key]: blob };
};
const getPreview = (file?: File | null): MetadataPreviewEntry => {
if (!file) {
@@ -56,48 +68,27 @@ export const useMetadataStore = defineStore('metadata', () => {
const key = makeKeyFromFile(file);
if (exiftoolInitStatus.value === 'error') {
const entry: MetadataPreviewEntry = {
status: 'error',
message: EXIFTOOL_PREPARE_ERROR_MESSAGE
};
setPreviewEntry(key, EXIFTOOL_PREPARE_ERROR_PREVIEW);
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
return entry;
return EXIFTOOL_PREPARE_ERROR_PREVIEW;
}
if (exiftoolInitStatus.value === 'ready') {
previewsByKey.value = {
...previewsByKey.value,
[key]: METADATA_LOADING_PREVIEW
};
} else {
previewsByKey.value = {
...previewsByKey.value,
[key]: { status: 'pending', message: EXIFTOOL_INITIALIZING_MESSAGE }
};
if (exiftoolInitStatus.value !== 'ready') {
setPreviewEntry(key, { status: 'initializing', message: EXIFTOOL_INITIALIZING_MESSAGE });
try {
await initializeExiftool();
previewsByKey.value = {
...previewsByKey.value,
[key]: METADATA_LOADING_PREVIEW
};
} catch (error) {
console.error('[metadata] ExifTool init failed', { file: file.name, error });
const entry: MetadataPreviewEntry = {
status: 'error',
message: EXIFTOOL_PREPARE_ERROR_MESSAGE
};
setPreviewEntry(key, EXIFTOOL_PREPARE_ERROR_PREVIEW);
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
return entry;
return EXIFTOOL_PREPARE_ERROR_PREVIEW;
}
}
setPreviewEntry(key, { status: 'processing', message: METADATA_PROCESSING_MESSAGE });
let output: string;
try {
@@ -105,14 +96,9 @@ export const useMetadataStore = defineStore('metadata', () => {
} catch (error) {
console.error('[metadata] read failed', { file: file.name, error });
const entry: MetadataPreviewEntry = {
status: 'error',
message: METADATA_LOAD_ERROR_MESSAGE
};
setPreviewEntry(key, METADATA_LOAD_ERROR_PREVIEW);
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
return entry;
return METADATA_LOAD_ERROR_PREVIEW;
}
let strippedBlob: Blob;
@@ -129,10 +115,11 @@ export const useMetadataStore = defineStore('metadata', () => {
const entry: MetadataPreviewEntry = {
status: 'error',
message: METADATA_STRIP_ERROR_MESSAGE
message: METADATA_STRIP_ERROR_MESSAGE,
output
};
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
setPreviewEntry(key, entry);
return entry;
}
@@ -149,17 +136,12 @@ export const useMetadataStore = defineStore('metadata', () => {
} catch (error) {
console.error('[metadata] read after strip failed', { file: file.name, error });
const entry: MetadataPreviewEntry = {
status: 'error',
message: METADATA_LOAD_ERROR_MESSAGE
};
setPreviewEntry(key, METADATA_LOAD_ERROR_PREVIEW);
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
return entry;
return METADATA_LOAD_ERROR_PREVIEW;
}
strippedBlobByKey.value = { ...strippedBlobByKey.value, [key]: strippedBlob };
setStrippedBlob(key, strippedBlob);
const entry: MetadataPreviewEntry = {
status: 'ready',
@@ -168,13 +150,22 @@ export const useMetadataStore = defineStore('metadata', () => {
afterStripOutput
};
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
setPreviewEntry(key, entry);
return entry;
};
const loadPreviews = (files: readonly File[]): Promise<MetadataPreviewEntry[]> =>
Promise.all(files.map(file => loadPreview(file)));
const loadPreviews = async (files: readonly File[]): Promise<MetadataPreviewEntry[]> => {
const results: MetadataPreviewEntry[] = [];
for (const file of files) {
const result = await loadPreview(file);
results.push(result);
}
return results;
};
const clearAllPreviews = (): void => {
previewsByKey.value = {};
@@ -184,7 +175,6 @@ export const useMetadataStore = defineStore('metadata', () => {
return {
previewsByKey,
strippedBlobByKey,
makeKeyFromFile,
getPreview,
getStrippedBlob,
loadPreview,
+226 -7
View File
@@ -1,12 +1,91 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { ALTCHA_REQUEST_HEADER } from '@/consts/altchaRequestHeader';
import { api } from '@/plugins/axios';
import { useAltchaStore } from '@/stores/altcha';
import { useMetadataStore } from '@/stores/metadata';
import type { CreateHotlinkUploadRequest } from '@/types/upload/CreateHotlinkUploadRequest';
import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest';
import type { CreatedUploadResult } from '@/types/upload/CreatedUploadResult';
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
import { buildUploadOptionsFromBatchSettings } from '@/utils/upload/buildUploadOptionsFromBatchSettings';
import { buildZkUploadPrepareOptionsFromBatchSettings } from '@/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings';
import { prepareHotlinkUploadRequest } from '@/utils/upload/prepareHotlinkUploadRequest';
import { prepareZkUploadRequest } from '@/utils/upload/prepareZkUploadRequest';
import type { PreparedZkUploadRequest } from '@/types/upload/PreparedZkUploadRequest';
import type { PreparedHotlinkUploadRequest } from '@/types/upload/PreparedHotlinkUploadRequest';
const UPLOAD_IDLE_MESSAGE = 'Waiting to upload...';
const UPLOAD_QUEUED_MESSAGE = 'Upload queued';
const UPLOAD_PREPARING_MESSAGE = 'Preparing upload...';
const UPLOAD_UPLOADING_MESSAGE = 'Uploading...';
const UPLOAD_UPLOADED_MESSAGE = 'Uploaded';
const UPLOAD_READ_ERROR_MESSAGE = 'Could not read image';
const UPLOAD_PROOF_OF_WORK_ERROR_MESSAGE = 'Proof of work failed';
const UPLOAD_FAILED_MESSAGE = 'Upload failed';
const UPLOAD_SKIPPED_METADATA_MESSAGE = 'Metadata not ready';
const UPLOAD_STRIPPED_MISSING_MESSAGE = 'Stripped image missing';
const UPLOAD_UNKNOWN_MODE_MESSAGE = 'Unknown share mode';
const UPLOAD_IDLE_ENTRY: UploadQueueEntry = {
status: 'idle',
message: UPLOAD_IDLE_MESSAGE
};
const UPLOAD_QUEUED_ENTRY: UploadQueueEntry = {
status: 'queued',
message: UPLOAD_QUEUED_MESSAGE
};
const UPLOAD_UPLOADING_ENTRY: UploadQueueEntry = {
status: 'uploading',
message: UPLOAD_UPLOADING_MESSAGE
};
const UPLOAD_UPLOADED_ENTRY: UploadQueueEntry = {
status: 'uploaded',
message: UPLOAD_UPLOADED_MESSAGE
};
const UPLOAD_FAILED_ENTRY: UploadQueueEntry = {
status: 'failed',
message: UPLOAD_FAILED_MESSAGE
};
const UPLOAD_STRIPPED_MISSING_ENTRY: UploadQueueEntry = {
status: 'failed',
message: UPLOAD_STRIPPED_MISSING_MESSAGE
};
export const useUploadStore = defineStore('upload', () => {
const createZkUpload = async (body: CreateZkUploadRequest, altcha?: string): Promise<CreatedUploadResult> => {
const uploadByKey = ref<Record<string, UploadQueueEntry>>({});
const metadataStore = useMetadataStore();
const altchaStore = useAltchaStore();
const { getPreview, getStrippedBlob } = metadataStore;
const setUploadEntry = (key: string, entry: UploadQueueEntry): void => {
uploadByKey.value = { ...uploadByKey.value, [key]: entry };
};
const getUpload = (file?: File | null): UploadQueueEntry => {
if (!file) {
return UPLOAD_IDLE_ENTRY;
}
const key = makeKeyFromFile(file);
return uploadByKey.value[key] ?? UPLOAD_IDLE_ENTRY;
};
const clearAllUploads = (): void => {
uploadByKey.value = {};
};
const uploadZk = async (body: CreateZkUploadRequest, altcha?: string): Promise<CreatedUploadResult> => {
const { data } = await api.post<CreatedUploadResult>('/uploads/zk', body, {
headers: getUploadRequestHeaders(altcha)
});
@@ -14,10 +93,7 @@ export const useUploadStore = defineStore('upload', () => {
return data;
};
const createHotlinkUpload = async (
body: CreateHotlinkUploadRequest,
altcha?: string
): Promise<CreatedUploadResult> => {
const uploadHotlink = async (body: CreateHotlinkUploadRequest, altcha?: string): Promise<CreatedUploadResult> => {
const { data } = await api.post<CreatedUploadResult>('/uploads/hotlink', body, {
headers: getUploadRequestHeaders(altcha)
});
@@ -27,8 +103,151 @@ export const useUploadStore = defineStore('upload', () => {
const getUploadRequestHeaders = (altcha?: string) => (altcha ? { [ALTCHA_REQUEST_HEADER]: altcha } : undefined);
const uploadBatch = async (
files: readonly File[],
settings: UploadBatchSettingsForm,
uploadDespiteMetadataFailure = false
): Promise<void> => {
initializeBatchUploadState(files, uploadDespiteMetadataFailure);
for (const file of files) {
const key = makeKeyFromFile(file);
const entry = uploadByKey.value[key];
if (!entry || entry.status !== 'queued') {
continue;
}
const strippedBlob = getStrippedBlob(file);
const preview = getPreview(file);
const uploadOriginal = !strippedBlob && uploadDespiteMetadataFailure && preview.status === 'error';
if (!strippedBlob && !uploadOriginal) {
setUploadEntry(key, UPLOAD_STRIPPED_MISSING_ENTRY);
continue;
}
const sourceBlob = strippedBlob ?? file;
setUploadEntry(key, { status: 'preparing', message: UPLOAD_PREPARING_MESSAGE });
let plaintext: Uint8Array;
const mimeType = sourceBlob.type || file.type || 'application/octet-stream';
try {
const arrayBuffer = await sourceBlob.arrayBuffer();
plaintext = new Uint8Array(arrayBuffer);
} catch (error) {
console.error('[upload] read stripped blob failed', { file: file.name, error });
setUploadEntry(key, {
status: 'failed',
message: UPLOAD_READ_ERROR_MESSAGE
});
continue;
}
let altcha: string | undefined;
try {
altcha = await altchaStore.runProofOfWork();
} catch (error) {
console.error('[upload] altcha failed', { file: file.name, error });
setUploadEntry(key, {
status: 'failed',
message: UPLOAD_PROOF_OF_WORK_ERROR_MESSAGE
});
continue;
}
if (settings.mode === 'zk') {
const options = buildZkUploadPrepareOptionsFromBatchSettings(settings);
let prepared: PreparedZkUploadRequest;
try {
prepared = await prepareZkUploadRequest(plaintext, options);
} catch (error) {
console.error('[upload] encrypt failed', { file: file.name, error });
setUploadEntry(key, UPLOAD_FAILED_ENTRY);
continue;
}
setUploadEntry(key, UPLOAD_UPLOADING_ENTRY);
try {
const { request } = prepared;
await uploadZk(request, altcha);
setUploadEntry(key, UPLOAD_UPLOADED_ENTRY);
} catch (error) {
console.error('[upload] upload failed', { file: file.name, error });
setUploadEntry(key, UPLOAD_FAILED_ENTRY);
}
} else if (settings.mode === 'hotlink') {
const options = buildUploadOptionsFromBatchSettings(settings);
let prepared: PreparedHotlinkUploadRequest;
try {
prepared = await prepareHotlinkUploadRequest(plaintext, mimeType, options);
} catch (error) {
console.error('[upload] encrypt failed', { file: file.name, error });
setUploadEntry(key, UPLOAD_FAILED_ENTRY);
continue;
}
setUploadEntry(key, UPLOAD_UPLOADING_ENTRY);
try {
const { request } = prepared;
await uploadHotlink(request, altcha);
setUploadEntry(key, UPLOAD_UPLOADED_ENTRY);
} catch (error) {
console.error('[upload] upload failed', { file: file.name, error });
setUploadEntry(key, UPLOAD_FAILED_ENTRY);
}
} else {
setUploadEntry(key, { status: 'failed', message: UPLOAD_UNKNOWN_MODE_MESSAGE });
}
}
};
const initializeBatchUploadState = (files: readonly File[], uploadDespiteMetadataFailure: boolean): void => {
uploadByKey.value = {};
for (const file of files) {
const key = makeKeyFromFile(file);
const preview = getPreview(file);
const strippedBlob = getStrippedBlob(file);
if (preview.status === 'ready') {
if (strippedBlob) {
setUploadEntry(key, UPLOAD_QUEUED_ENTRY);
} else {
setUploadEntry(key, UPLOAD_STRIPPED_MISSING_ENTRY);
}
} else if (preview.status === 'error' && uploadDespiteMetadataFailure) {
setUploadEntry(key, UPLOAD_QUEUED_ENTRY);
} else {
setUploadEntry(key, { status: 'skipped', message: UPLOAD_SKIPPED_METADATA_MESSAGE });
}
}
};
return {
createZkUpload,
createHotlinkUpload
uploadByKey,
getUpload,
clearAllUploads,
uploadBatch,
uploadZk,
uploadHotlink
};
});
@@ -1,6 +1,6 @@
export type MetadataPreviewEntry =
| { status: 'idle'; message: string }
| { status: 'pending'; message: string }
| { status: 'loading'; message: string }
| { status: 'initializing'; message: string }
| { status: 'processing'; message: string }
| { status: 'ready'; message: string; output: string; afterStripOutput: string }
| { status: 'error'; message: string };
| { status: 'error'; message: string; output?: string };
@@ -0,0 +1,6 @@
import type { CreateHotlinkUploadRequest } from './CreateHotlinkUploadRequest';
export type PreparedHotlinkUploadRequest = {
secret: string;
request: CreateHotlinkUploadRequest;
};
@@ -0,0 +1,6 @@
import type { CreateZkUploadRequest } from './CreateZkUploadRequest';
export type PreparedZkUploadRequest = {
fragmentKey: string;
request: CreateZkUploadRequest;
};
@@ -0,0 +1,8 @@
export type UploadQueueEntry =
| { status: 'idle'; message: string }
| { status: 'queued'; message: string }
| { status: 'preparing'; message: string }
| { status: 'uploading'; message: string }
| { status: 'uploaded'; message: string }
| { status: 'failed'; message: string }
| { status: 'skipped'; message: string };
+3 -1
View File
@@ -1,7 +1,9 @@
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
export type UploadQueueRow = {
file: File;
key: string;
preview: MetadataPreviewEntry;
metadata: MetadataPreviewEntry;
upload: UploadQueueEntry;
};
@@ -1,5 +0,0 @@
import type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions';
export type ZkUploadPrepareOptionsResult =
| { ok: true; options: ZkUploadPrepareOptions }
| { ok: false; error: string };
@@ -0,0 +1,2 @@
export const makeKeyFromFile = (file: File): string =>
`${file.name}\0${file.size}\0${file.lastModified}`;
@@ -0,0 +1,9 @@
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
import { resolveUploadBatchExpiresAt } from '@/utils/upload/resolveUploadBatchExpiresAt';
import { resolveUploadBatchMaxViews } from '@/utils/upload/resolveUploadBatchMaxViews';
export const buildUploadOptionsFromBatchSettings = (settings: UploadBatchSettingsForm): UploadOptionsRequest => ({
expiresAt: resolveUploadBatchExpiresAt(settings.expiryPreset),
maxViews: resolveUploadBatchMaxViews(settings.maxViewsPreset)
});
@@ -27,15 +27,10 @@ describe('buildZkUploadPrepareOptionsFromBatchSettings', () => {
maxViewsPreset: '10' as const
};
const result = buildZkUploadPrepareOptionsFromBatchSettings(settings);
expect(result).toEqual({
ok: true,
options: {
expiresAt: '2026-06-02T00:00:00.000Z',
maxViews: 10,
passwordProtected: false
}
expect(buildZkUploadPrepareOptionsFromBatchSettings(settings)).toEqual({
expiresAt: '2026-06-02T00:00:00.000Z',
maxViews: 10,
passwordProtected: false
});
});
@@ -46,29 +41,11 @@ describe('buildZkUploadPrepareOptionsFromBatchSettings', () => {
password: 'batch-secret'
};
const result = buildZkUploadPrepareOptionsFromBatchSettings(settings);
expect(result).toEqual({
ok: true,
options: {
expiresAt: null,
maxViews: null,
passwordProtected: true,
password: 'batch-secret'
}
});
});
it('returns validation errors', () => {
const settings = {
...baseSettings,
passwordEnabled: true,
password: ''
};
expect(buildZkUploadPrepareOptionsFromBatchSettings(settings)).toEqual({
ok: false,
error: 'Enter a password or turn off password protection.'
expiresAt: null,
maxViews: null,
passwordProtected: true,
password: 'batch-secret'
});
});
});
@@ -1,39 +1,22 @@
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import type { ZkUploadPrepareOptionsResult } from '@/types/upload/ZkUploadPrepareOptionsResult';
import { resolveUploadBatchExpiresAt } from '@/utils/upload/resolveUploadBatchExpiresAt';
import { resolveUploadBatchMaxViews } from '@/utils/upload/resolveUploadBatchMaxViews';
import { validateUploadBatchSettings } from '@/utils/upload/validateUploadBatchSettings';
import type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions';
import { buildUploadOptionsFromBatchSettings } from '@/utils/upload/buildUploadOptionsFromBatchSettings';
export const buildZkUploadPrepareOptionsFromBatchSettings = (
settings: UploadBatchSettingsForm
): ZkUploadPrepareOptionsResult => {
const validationError = validateUploadBatchSettings(settings);
if (validationError) {
return { ok: false, error: validationError };
}
const uploadOptions = {
expiresAt: resolveUploadBatchExpiresAt(settings.expiryPreset),
maxViews: resolveUploadBatchMaxViews(settings.maxViewsPreset)
};
): ZkUploadPrepareOptions => {
const uploadOptions = buildUploadOptionsFromBatchSettings(settings);
if (settings.passwordEnabled) {
return {
ok: true,
options: {
...uploadOptions,
passwordProtected: true,
password: settings.password
}
...uploadOptions,
passwordProtected: true,
password: settings.password
};
}
return {
ok: true,
options: {
...uploadOptions,
passwordProtected: false
}
...uploadOptions,
passwordProtected: false
};
};
@@ -1,4 +1,4 @@
import type { CreateHotlinkUploadRequest } from '@/types/upload/CreateHotlinkUploadRequest';
import type { PreparedHotlinkUploadRequest } from '@/types/upload/PreparedHotlinkUploadRequest';
import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
import { encryptHotlinkUpload } from '@/utils/upload/encryptHotlinkUpload';
@@ -7,7 +7,7 @@ export const prepareHotlinkUploadRequest = async (
plaintext: Uint8Array,
mimeType: string,
options: UploadOptionsRequest
): Promise<{ request: CreateHotlinkUploadRequest; secret: string }> => {
): Promise<PreparedHotlinkUploadRequest> => {
const encrypted = await encryptHotlinkUpload(plaintext);
return {
@@ -1,4 +1,4 @@
import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest';
import type { PreparedZkUploadRequest } from '@/types/upload/PreparedZkUploadRequest';
import type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions';
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url';
@@ -9,7 +9,7 @@ import { wrapZkDek } from '@/utils/upload/wrapZkDek';
export const prepareZkUploadRequest = async (
plaintext: Uint8Array,
options: ZkUploadPrepareOptions
): Promise<{ request: CreateZkUploadRequest; fragmentKey: string }> => {
): Promise<PreparedZkUploadRequest> => {
if (!options.passwordProtected) {
const { dek, iv, ciphertext } = await encryptZkUpload(plaintext);