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_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 uploadByKey = ref>({}); 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 => { const { data } = await api.post('/uploads/zk', body, { headers: getUploadRequestHeaders(altcha) }); return data; }; const uploadHotlink = async (body: CreateHotlinkUploadRequest, altcha?: string): Promise => { const { data } = await api.post('/uploads/hotlink', body, { headers: getUploadRequestHeaders(altcha) }); return data; }; const deleteUpload = async (uploadId: string, deleteToken: string): Promise => { await api.delete(`/uploads/${uploadId}`, { params: { delete_token: deleteToken } }); }; const getUploadRequestHeaders = (altcha?: string) => (altcha ? { [ALTCHA_REQUEST_HEADER]: altcha } : undefined); const uploadBatch = async ( files: readonly File[], settings: UploadBatchSettingsForm, uploadDespiteMetadataFailure = false ): Promise => { 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, fragmentKey } = prepared; const result = await uploadZk(request, altcha); setUploadEntry(key, { status: 'uploaded', message: UPLOAD_UPLOADED_MESSAGE, uploadId: result.id, mode: 'zk', deleteToken: result.deleteToken, urlSecret: fragmentKey }); } 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, secret } = prepared; const result = await uploadHotlink(request, altcha); setUploadEntry(key, { status: 'uploaded', message: UPLOAD_UPLOADED_MESSAGE, uploadId: result.id, mode: 'hotlink', deleteToken: result.deleteToken, urlSecret: secret }); } 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 { uploadByKey, getUpload, clearAllUploads, uploadBatch, uploadZk, uploadHotlink, deleteUpload }; });