diff --git a/apps/frontend/__tests__/unit/services/bulkObjectDownload.spec.ts b/apps/frontend/__tests__/unit/services/bulkObjectDownload.spec.ts new file mode 100644 index 00000000..c6938598 --- /dev/null +++ b/apps/frontend/__tests__/unit/services/bulkObjectDownload.spec.ts @@ -0,0 +1,228 @@ +jest.mock('@auto-drive/models', () => ({ + isInsecure: (tags: string[]) => tags.includes('insecure'), + isBanned: (tags: string[]) => tags.includes('banned'), +})); + +import { + BulkDownloadItem, + EncryptionContext, + hasEncryption, + itemIsRunnable, + resolveEncryptionOptions, + shouldSkipEncrypted, + shouldSkipInsecure, +} from '../../../src/services/bulkObjectDownload'; + +const baseInformation = { + tags: [] as string[], + metadata: { + dataCid: 'bafkr6itestcid', + name: 'test.txt', + type: 'file', + totalSize: 12, + uploadOptions: {}, + }, +} as unknown as BulkDownloadItem['information']; + +interface InformationOverrides { + tags?: string[]; + metadata?: Record; +} + +const makeItem = ( + overrides: Partial = {}, + informationOverrides: InformationOverrides = {}, +): BulkDownloadItem => ({ + cid: 'bafkr6itestcid', + status: 'pending', + ...overrides, + information: { + ...baseInformation, + ...informationOverrides, + metadata: { + ...baseInformation!.metadata, + ...(informationOverrides.metadata ?? {}), + }, + } as BulkDownloadItem['information'], +}); + +const encryptionMetadata = { + uploadOptions: { + encryption: { algorithm: 'aes-256-gcm' }, + }, +}; + +describe('hasEncryption', () => { + it('returns true when the upload options declare an encryption algorithm', () => { + expect(hasEncryption(makeItem({}, { metadata: encryptionMetadata }))).toBe( + true, + ); + }); + + it('returns false when no encryption algorithm is declared', () => { + expect(hasEncryption(makeItem())).toBe(false); + }); + + it('returns false for items without metadata', () => { + expect( + hasEncryption({ cid: 'x', status: 'failed' } as BulkDownloadItem), + ).toBe(false); + }); +}); + +describe('itemIsRunnable', () => { + it('is true for pending items with information', () => { + expect(itemIsRunnable(makeItem({ status: 'pending' }))).toBe(true); + }); + + it('is false for non-pending items', () => { + for (const status of [ + 'skipped', + 'checking', + 'preparing', + 'downloading', + 'completed', + 'failed', + ] as const) { + expect(itemIsRunnable(makeItem({ status }))).toBe(false); + } + }); + + it('is false when information is missing (metadata load failed)', () => { + expect( + itemIsRunnable({ + cid: 'x', + status: 'pending', + } as BulkDownloadItem), + ).toBe(false); + }); +}); + +describe('shouldSkipInsecure', () => { + it('skips insecure items when the user has not confirmed', () => { + const item = makeItem({}, { tags: ['insecure'] }); + expect(shouldSkipInsecure(item, false)).toBe(true); + }); + + it('does not skip insecure items once confirmed', () => { + const item = makeItem({}, { tags: ['insecure'] }); + expect(shouldSkipInsecure(item, true)).toBe(false); + }); + + it('does not skip non-insecure items', () => { + expect(shouldSkipInsecure(makeItem(), false)).toBe(false); + }); + + it('does not skip when information is missing', () => { + expect( + shouldSkipInsecure( + { cid: 'x', status: 'pending' } as BulkDownloadItem, + false, + ), + ).toBe(false); + }); +}); + +describe('shouldSkipEncrypted', () => { + const encryptedItem = makeItem({}, { metadata: encryptionMetadata }); + + it('skips encrypted items when the user chose skip and has no default password', () => { + const ctx: EncryptionContext = { + defaultPassword: undefined, + encryptionChoice: 'skip', + sharedPassword: '', + }; + expect(shouldSkipEncrypted(encryptedItem, ctx)).toBe(true); + }); + + it('does not skip encrypted items when a default password is set', () => { + const ctx: EncryptionContext = { + defaultPassword: 'pw', + encryptionChoice: 'skip', + sharedPassword: '', + }; + expect(shouldSkipEncrypted(encryptedItem, ctx)).toBe(false); + }); + + it('does not skip when user chose download-encrypted', () => { + const ctx: EncryptionContext = { + defaultPassword: undefined, + encryptionChoice: 'download-encrypted', + sharedPassword: '', + }; + expect(shouldSkipEncrypted(encryptedItem, ctx)).toBe(false); + }); + + it('does not skip non-encrypted items even with skip choice', () => { + const ctx: EncryptionContext = { + defaultPassword: undefined, + encryptionChoice: 'skip', + sharedPassword: '', + }; + expect(shouldSkipEncrypted(makeItem(), ctx)).toBe(false); + }); +}); + +describe('resolveEncryptionOptions', () => { + const encryptedItem = makeItem({}, { metadata: encryptionMetadata }); + + it('returns no password and no skip for non-encrypted items', () => { + expect( + resolveEncryptionOptions(makeItem(), { + defaultPassword: 'pw', + encryptionChoice: 'shared-password', + sharedPassword: 'shared', + }), + ).toEqual({ password: undefined, skipDecryption: false }); + }); + + it('uses the default password when one is set', () => { + expect( + resolveEncryptionOptions(encryptedItem, { + defaultPassword: 'defaultpw', + encryptionChoice: null, + sharedPassword: '', + }), + ).toEqual({ password: 'defaultpw', skipDecryption: false }); + }); + + it('returns skipDecryption=true when user chose download-encrypted', () => { + expect( + resolveEncryptionOptions(encryptedItem, { + defaultPassword: undefined, + encryptionChoice: 'download-encrypted', + sharedPassword: 'ignored', + }), + ).toEqual({ password: undefined, skipDecryption: true }); + }); + + it('uses the shared password when user chose shared-password', () => { + expect( + resolveEncryptionOptions(encryptedItem, { + defaultPassword: undefined, + encryptionChoice: 'shared-password', + sharedPassword: 'sharedpw', + }), + ).toEqual({ password: 'sharedpw', skipDecryption: false }); + }); + + it('falls back to no-password/no-skip when user chose skip', () => { + expect( + resolveEncryptionOptions(encryptedItem, { + defaultPassword: undefined, + encryptionChoice: 'skip', + sharedPassword: '', + }), + ).toEqual({ password: undefined, skipDecryption: false }); + }); + + it('prefers default password over an explicit shared-password choice', () => { + expect( + resolveEncryptionOptions(encryptedItem, { + defaultPassword: 'defaultpw', + encryptionChoice: 'shared-password', + sharedPassword: 'sharedpw', + }), + ).toEqual({ password: 'defaultpw', skipDecryption: false }); + }); +}); diff --git a/apps/frontend/__tests__/unit/services/objectDownloadFlow.spec.ts b/apps/frontend/__tests__/unit/services/objectDownloadFlow.spec.ts new file mode 100644 index 00000000..0325867e --- /dev/null +++ b/apps/frontend/__tests__/unit/services/objectDownloadFlow.spec.ts @@ -0,0 +1,273 @@ +jest.mock('utils/auth', () => ({ + getAuthSession: jest.fn(), +})); + +jest.mock('@auto-drive/models', () => ({ + AsyncDownloadStatus: { + Failed: 'failed', + Dismissed: 'dismissed', + }, + DownloadStatus: { + Cached: 'cached', + NotCached: 'not-cached', + }, +})); + +import { AsyncDownloadStatus, DownloadStatus } from '@auto-drive/models'; +import { getAuthSession } from 'utils/auth'; +import { runObjectDownloadFlow } from '../../../src/services/objectDownloadFlow'; + +const metadata = { + dataCid: 'bafkr6itestcid', + name: 'test.txt', + type: 'file', + totalSize: 12, + uploadOptions: {}, +}; + +const createDependencies = () => { + const api = { + checkDownloadStatus: jest.fn(), + createAsyncDownload: jest.fn(), + }; + const downloadService = { + fetchFile: jest.fn(), + }; + + return { api, downloadService }; +}; + +describe('runObjectDownloadFlow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('downloads immediately when the object is already cached', async () => { + (getAuthSession as jest.Mock).mockResolvedValue({ + accessToken: 'token', + authProvider: 'google', + }); + const { api, downloadService } = createDependencies(); + api.checkDownloadStatus.mockResolvedValue(DownloadStatus.Cached); + + await runObjectDownloadFlow({ + api: api as never, + downloadService, + metadata: metadata as never, + password: 'secret', + onPhaseChange: jest.fn(), + }); + + expect(api.checkDownloadStatus).toHaveBeenCalledWith(metadata.dataCid); + expect(api.createAsyncDownload).not.toHaveBeenCalled(); + expect(downloadService.fetchFile).toHaveBeenCalledWith(metadata.dataCid, { + password: 'secret', + skipDecryption: false, + onProgress: undefined, + }); + }); + + it('creates an async download for uncached authenticated objects before downloading', async () => { + (getAuthSession as jest.Mock).mockResolvedValue({ + accessToken: 'token', + authProvider: 'google', + }); + const { api, downloadService } = createDependencies(); + api.checkDownloadStatus + .mockResolvedValueOnce(DownloadStatus.NotCached) + .mockResolvedValueOnce(DownloadStatus.Cached); + const onPhaseChange = jest.fn(); + const onAsyncDownloadsRefresh = jest.fn(); + + await runObjectDownloadFlow({ + api: api as never, + downloadService, + metadata: metadata as never, + asyncPollIntervalMs: 0, + onPhaseChange, + onAsyncDownloadsRefresh, + }); + + expect(api.createAsyncDownload).toHaveBeenCalledWith(metadata.dataCid); + expect(onAsyncDownloadsRefresh).toHaveBeenCalled(); + expect(onPhaseChange.mock.calls.map(([phase]) => phase)).toEqual([ + 'checking', + 'preparing', + 'downloading', + 'completed', + ]); + expect(downloadService.fetchFile).toHaveBeenCalledTimes(1); + }); + + it('falls back to direct download when async creation fails', async () => { + (getAuthSession as jest.Mock).mockResolvedValue({ + accessToken: 'token', + authProvider: 'google', + }); + const { api, downloadService } = createDependencies(); + api.checkDownloadStatus.mockResolvedValueOnce(DownloadStatus.NotCached); + api.createAsyncDownload.mockRejectedValue(new Error('queue unavailable')); + + await runObjectDownloadFlow({ + api: api as never, + downloadService, + metadata: metadata as never, + asyncPollIntervalMs: 0, + }); + + expect(downloadService.fetchFile).toHaveBeenCalledTimes(1); + }); + + it('surfaces async preparation failures from the async downloads store', async () => { + (getAuthSession as jest.Mock).mockResolvedValue({ + accessToken: 'token', + authProvider: 'google', + }); + const { api, downloadService } = createDependencies(); + api.checkDownloadStatus + .mockResolvedValueOnce(DownloadStatus.NotCached) + .mockResolvedValueOnce(DownloadStatus.NotCached); + + await expect( + runObjectDownloadFlow({ + api: api as never, + downloadService, + metadata: metadata as never, + asyncPollIntervalMs: 0, + getAsyncDownloads: () => [ + { + cid: metadata.dataCid, + status: AsyncDownloadStatus.Failed, + errorMessage: 'Gateway timed out', + }, + ], + }), + ).rejects.toThrow('Gateway timed out'); + + expect(downloadService.fetchFile).not.toHaveBeenCalled(); + }); + + it('downloads directly for anonymous users', async () => { + (getAuthSession as jest.Mock).mockResolvedValue(null); + const { api, downloadService } = createDependencies(); + + await runObjectDownloadFlow({ + api: api as never, + downloadService, + metadata: metadata as never, + }); + + expect(api.checkDownloadStatus).not.toHaveBeenCalled(); + expect(api.createAsyncDownload).not.toHaveBeenCalled(); + expect(downloadService.fetchFile).toHaveBeenCalledTimes(1); + }); + + it('throws ObjectDownloadPreparationError when async polling times out', async () => { + (getAuthSession as jest.Mock).mockResolvedValue({ + accessToken: 'token', + authProvider: 'google', + }); + const { api, downloadService } = createDependencies(); + // initial check → NotCached triggers async creation; every subsequent + // poll also returns NotCached, exhausting maxAsyncPollCount. + api.checkDownloadStatus.mockResolvedValue(DownloadStatus.NotCached); + + await expect( + runObjectDownloadFlow({ + api: api as never, + downloadService, + metadata: metadata as never, + asyncPollIntervalMs: 0, + maxAsyncPollCount: 3, + }), + ).rejects.toThrow('Download preparation timed out'); + + expect(api.createAsyncDownload).toHaveBeenCalledTimes(1); + expect(downloadService.fetchFile).not.toHaveBeenCalled(); + }); + + it('aborts mid-poll when the AbortSignal fires', async () => { + (getAuthSession as jest.Mock).mockResolvedValue({ + accessToken: 'token', + authProvider: 'google', + }); + const { api, downloadService } = createDependencies(); + api.checkDownloadStatus.mockResolvedValue(DownloadStatus.NotCached); + + const controller = new AbortController(); + const flow = runObjectDownloadFlow({ + api: api as never, + downloadService, + metadata: metadata as never, + asyncPollIntervalMs: 100, + maxAsyncPollCount: 10, + signal: controller.signal, + }); + + // give the flow a chance to enter its first delay() + await Promise.resolve(); + await Promise.resolve(); + controller.abort(); + + await expect(flow).rejects.toThrow('Download aborted'); + expect(downloadService.fetchFile).not.toHaveBeenCalled(); + }); + + it('aborts after fetchFile completes if signal fired mid-download', async () => { + (getAuthSession as jest.Mock).mockResolvedValue(null); + const { api, downloadService } = createDependencies(); + + const controller = new AbortController(); + downloadService.fetchFile.mockImplementation(() => { + controller.abort(); + return Promise.resolve(); + }); + + const onPhaseChange = jest.fn(); + + await expect( + runObjectDownloadFlow({ + api: api as never, + downloadService, + metadata: metadata as never, + signal: controller.signal, + onPhaseChange, + }), + ).rejects.toThrow('Download aborted'); + + expect(downloadService.fetchFile).toHaveBeenCalledTimes(1); + expect(onPhaseChange).toHaveBeenCalledWith('downloading'); + expect(onPhaseChange).not.toHaveBeenCalledWith('completed'); + }); + + it('tolerates a transient poll-cycle error and continues polling', async () => { + (getAuthSession as jest.Mock).mockResolvedValue({ + accessToken: 'token', + authProvider: 'google', + }); + const { api, downloadService } = createDependencies(); + // 1st call (pre-async): NotCached + // 2nd call (1st poll): rejects with transient error → swallowed + // 3rd call (2nd poll): Cached → flow proceeds to download + api.checkDownloadStatus + .mockResolvedValueOnce(DownloadStatus.NotCached) + .mockRejectedValueOnce(new Error('network blip')) + .mockResolvedValueOnce(DownloadStatus.Cached); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await runObjectDownloadFlow({ + api: api as never, + downloadService, + metadata: metadata as never, + asyncPollIntervalMs: 0, + maxAsyncPollCount: 5, + }); + + expect(downloadService.fetchFile).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][0]).toContain('poll cycle'); + + warnSpy.mockRestore(); + }); +}); diff --git a/apps/frontend/src/components/molecules/BulkObjectDownloadModal.tsx b/apps/frontend/src/components/molecules/BulkObjectDownloadModal.tsx new file mode 100644 index 00000000..9a3ff1d9 --- /dev/null +++ b/apps/frontend/src/components/molecules/BulkObjectDownloadModal.tsx @@ -0,0 +1,660 @@ +import { + Dialog, + DialogPanel, + DialogTitle, + Transition, + TransitionChild, +} from '@headlessui/react'; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + isBanned, + isInsecure, + ObjectInformation, + ObjectStatus, +} from '@auto-drive/models'; +import { Button } from '@auto-drive/ui'; +import toast from 'react-hot-toast'; +import { useNetwork } from 'contexts/network'; +import { useEncryptionStore } from 'globalStates/encryption'; +import { InvalidDecryptKey } from 'utils/file'; +import { + ObjectDownloadAbortedError, + runObjectDownloadFlow, +} from 'services/objectDownloadFlow'; +import { + BulkDownloadItem, + BulkDownloadStatus, + EncryptionChoice, + hasEncryption, + itemIsRunnable, + resolveEncryptionOptions as resolveBulkEncryptionOptions, + shouldSkipEncrypted, + shouldSkipInsecure, +} from 'services/bulkObjectDownload'; +import { formatBytes } from 'utils/number'; +import { shortenString } from 'utils/misc'; +import { useUserAsyncDownloadsStore } from '../organisms/UserAsyncDownloads/state'; + +const toastId = 'bulk-object-download-modal'; + +const statusLabel: Record = { + pending: 'Pending', + skipped: 'Skipped', + checking: 'Checking', + preparing: 'Preparing', + downloading: 'Downloading', + completed: 'Completed', + failed: 'Failed', +}; + +const itemName = (item: BulkDownloadItem) => + item.information?.metadata.name ?? item.cid; + +export const BulkObjectDownloadModal = ({ + cids, + isOpen, + onClose, + onComplete, +}: { + cids: string[]; + isOpen: boolean; + onClose: () => void; + onComplete: () => void; +}) => { + const network = useNetwork(); + const defaultPassword = useEncryptionStore((store) => store.password); + const updateAsyncDownloads = useUserAsyncDownloadsStore((e) => e.update); + const addPendingAutoDownload = useUserAsyncDownloadsStore( + (e) => e.addPendingAutoDownload, + ); + const [items, setItems] = useState([]); + const [isLoadingMetadata, setIsLoadingMetadata] = useState(false); + const [hasConfirmedInsecure, setHasConfirmedInsecure] = useState(false); + const [encryptionChoice, setEncryptionChoice] = + useState(null); + const [sharedPassword, setSharedPassword] = useState(''); + const [isRunning, setIsRunning] = useState(false); + const [isComplete, setIsComplete] = useState(false); + const [pendingRetryStart, setPendingRetryStart] = useState(false); + const abortRef = useRef(null); + const currentAsyncItemRef = useRef<{ + item: BulkDownloadItem; + password?: string; + skipDecryption: boolean; + } | null>(null); + + const updateItem = useCallback( + (cid: string, updater: (item: BulkDownloadItem) => BulkDownloadItem) => { + setItems((current) => + current.map((item) => (item.cid === cid ? updater(item) : item)), + ); + }, + [], + ); + + useEffect(() => { + if (!isOpen) { + abortRef.current?.abort(); + abortRef.current = null; + currentAsyncItemRef.current = null; + setItems([]); + setIsLoadingMetadata(false); + setHasConfirmedInsecure(false); + setEncryptionChoice(null); + setSharedPassword(''); + setIsRunning(false); + setIsComplete(false); + setPendingRetryStart(false); + return; + } + + let cancelled = false; + setIsLoadingMetadata(true); + setItems(cids.map((cid) => ({ cid, status: 'pending' }))); + + Promise.all( + cids.map(async (cid): Promise => { + try { + const information = + await network.api.fetchUploadedObjectMetadata(cid); + if (isBanned(information.tags)) { + return { + cid, + information, + status: 'skipped', + skippedReason: 'File is banned', + }; + } + if ( + information.status === ObjectStatus.Processing || + information.uploadState.totalNodes === null + ) { + return { + cid, + information, + status: 'skipped', + skippedReason: 'Upload is still processing', + }; + } + return { cid, information, status: 'pending' }; + } catch (error) { + return { + cid, + status: 'failed', + error: + error instanceof Error && error.message + ? error.message + : 'Failed to load metadata', + }; + } + }), + ).then((loadedItems) => { + if (cancelled) return; + setItems(loadedItems); + setIsLoadingMetadata(false); + }); + + return () => { + cancelled = true; + }; + }, [cids, isOpen, network.api]); + + const insecureItems = useMemo( + () => + items.filter( + (item) => + item.status === 'pending' && + item.information && + isInsecure(item.information.tags), + ), + [items], + ); + + const encryptedItemsNeedingDecision = useMemo( + () => + defaultPassword + ? [] + : items.filter( + (item) => item.status === 'pending' && hasEncryption(item), + ), + [defaultPassword, items], + ); + + const canStart = + !isLoadingMetadata && + !isRunning && + items.some(itemIsRunnable) && + (insecureItems.length === 0 || hasConfirmedInsecure) && + (encryptedItemsNeedingDecision.length === 0 || + encryptionChoice === 'download-encrypted' || + encryptionChoice === 'skip' || + (encryptionChoice === 'shared-password' && sharedPassword.length > 0)); + + const encryptionContext = useMemo( + () => ({ defaultPassword, encryptionChoice, sharedPassword }), + [defaultPassword, encryptionChoice, sharedPassword], + ); + + const resolveEncryptionOptions = useCallback( + (item: BulkDownloadItem) => + resolveBulkEncryptionOptions(item, encryptionContext), + [encryptionContext], + ); + + const startQueue = useCallback(async () => { + if (!canStart) return; + + const abortController = new AbortController(); + abortRef.current = abortController; + setIsRunning(true); + setIsComplete(false); + + const runnableItems = items.filter(itemIsRunnable); + for (const item of runnableItems) { + if (abortController.signal.aborted) break; + + if (shouldSkipEncrypted(item, encryptionContext)) { + updateItem(item.cid, (current) => ({ + ...current, + status: 'skipped', + skippedReason: 'Encrypted file skipped', + })); + continue; + } + + // Defense-in-depth: even though `canStart` is gated by + // `hasConfirmedInsecure`, refuse to download insecure files at the + // runtime boundary so future changes to the UI gate can't bypass it. + if (shouldSkipInsecure(item, hasConfirmedInsecure)) { + updateItem(item.cid, (current) => ({ + ...current, + status: 'skipped', + skippedReason: 'Insecure file not confirmed', + })); + continue; + } + + const { password, skipDecryption } = resolveEncryptionOptions(item); + + try { + await runObjectDownloadFlow({ + api: network.api, + downloadService: network.downloadService, + metadata: item.information.metadata, + password, + skipDecryption, + signal: abortController.signal, + onAsyncDownloadsRefresh: updateAsyncDownloads, + getAsyncDownloads: () => + useUserAsyncDownloadsStore.getState().asyncDownloads, + onProgress: (progress) => { + updateItem(item.cid, (current) => ({ + ...current, + progress, + })); + }, + onPhaseChange: (phase) => { + if (phase === 'preparing') { + currentAsyncItemRef.current = { + item, + password, + skipDecryption, + }; + } else if (currentAsyncItemRef.current?.item.cid === item.cid) { + currentAsyncItemRef.current = null; + } + + updateItem(item.cid, (current) => ({ + ...current, + status: phase, + error: undefined, + })); + }, + }); + toast.success(`${shortenString(itemName(item), 30)} downloaded`, { + id: `${toastId}-${item.cid}`, + }); + } catch (error) { + if (currentAsyncItemRef.current?.item.cid === item.cid) { + currentAsyncItemRef.current = null; + } + + if (error instanceof ObjectDownloadAbortedError) { + break; + } + + const errorMessage = + error instanceof InvalidDecryptKey + ? 'Wrong password' + : error instanceof Error && error.message + ? error.message + : 'Download failed'; + updateItem(item.cid, (current) => ({ + ...current, + status: 'failed', + error: errorMessage, + })); + toast.error(`Failed to download ${shortenString(itemName(item), 30)}`, { + id: `${toastId}-${item.cid}`, + }); + } + } + + currentAsyncItemRef.current = null; + setIsRunning(false); + setIsComplete(true); + }, [ + canStart, + encryptionContext, + hasConfirmedInsecure, + items, + network.api, + network.downloadService, + resolveEncryptionOptions, + updateAsyncDownloads, + updateItem, + ]); + + const retryFailed = useCallback(() => { + setItems((current) => + current.map((item) => + item.status === 'failed' && item.information + ? { + ...item, + status: 'pending', + error: undefined, + progress: null, + } + : item, + ), + ); + setIsComplete(false); + setPendingRetryStart(true); + }, []); + + useEffect(() => { + if (pendingRetryStart && canStart) { + setPendingRetryStart(false); + startQueue(); + } + }, [pendingRetryStart, canStart, startQueue]); + + const closeModal = useCallback(() => { + // Only background remaining items when the queue was actually started. + // Without this guard, clicking Cancel before Start Download would + // spuriously kick off async downloads for every selected file. + if (isRunning) { + const currentAsync = currentAsyncItemRef.current; + let backgroundedCount = 0; + + const remainingNotStarted = items + .filter( + (item): item is BulkDownloadItem & { information: ObjectInformation } => + (item.status === 'pending' || item.status === 'checking') && + !!item.information && + item.cid !== currentAsync?.item.cid, + ) + .filter( + (item) => + !shouldSkipEncrypted(item, encryptionContext) && + !shouldSkipInsecure(item, hasConfirmedInsecure), + ); + + if (currentAsync?.item.information) { + addPendingAutoDownload({ + cid: currentAsync.item.information.metadata.dataCid, + password: currentAsync.skipDecryption + ? undefined + : currentAsync.password, + skipDecryption: currentAsync.skipDecryption, + fileName: currentAsync.item.information.metadata.name ?? undefined, + }); + backgroundedCount += 1; + } + + const backgroundPromises = remainingNotStarted.map((item) => { + const { password: itemPassword, skipDecryption } = + resolveBulkEncryptionOptions(item, encryptionContext); + const cid = item.information.metadata.dataCid; + const fileName = item.information.metadata.name ?? undefined; + + return network.api + .createAsyncDownload(cid) + .then(() => { + addPendingAutoDownload({ + cid, + password: skipDecryption ? undefined : itemPassword, + skipDecryption, + fileName, + }); + return true as const; + }) + .catch((err) => { + console.warn( + `[BulkObjectDownloadModal] failed to background ${cid}`, + err, + ); + return false as const; + }); + }); + + if (backgroundedCount > 0 || backgroundPromises.length > 0) { + Promise.all(backgroundPromises).then((results) => { + const totalCount = + backgroundedCount + results.filter(Boolean).length; + if (totalCount > 0) { + updateAsyncDownloads(); + toast.success( + `${totalCount} download${totalCount === 1 ? '' : 's'} will continue in the background. Check Cached Downloads for progress.`, + { id: toastId, duration: 5000 }, + ); + } + }); + } + } + + abortRef.current?.abort(); + if (isComplete) { + onComplete(); + } + onClose(); + }, [ + addPendingAutoDownload, + encryptionContext, + hasConfirmedInsecure, + isComplete, + isRunning, + items, + network.api, + onClose, + onComplete, + updateAsyncDownloads, + ]); + + const handleDialogClose = useCallback(() => { + // Backdrop / Esc close should not abort an in-flight queue — require the + // user to explicitly hit Close while running to background what we can. + if (isRunning) return; + closeModal(); + }, [isRunning, closeModal]); + + const completedCount = items.filter( + (item) => item.status === 'completed', + ).length; + const failedCount = items.filter((item) => item.status === 'failed').length; + const skippedCount = items.filter((item) => item.status === 'skipped').length; + + return ( + + + +
+ + +
+
+ + +
+
+ + Download {cids.length} files + +

+ {completedCount} completed, {failedCount} failed,{' '} + {skippedCount} skipped +

+
+ {isLoadingMetadata && ( +
+ )} +
+ + {insecureItems.length > 0 && !hasConfirmedInsecure && ( +
+ {insecureItems.length} selected file + {insecureItems.length === 1 ? ' is' : 's are'} marked + insecure. + +
+ )} + + {encryptedItemsNeedingDecision.length > 0 && + (insecureItems.length === 0 || hasConfirmedInsecure) && ( +
+

+ {encryptedItemsNeedingDecision.length} encrypted file + {encryptedItemsNeedingDecision.length === 1 + ? '' + : 's'}{' '} + need a bulk decision. +

+
+ + + +
+ {encryptionChoice === 'shared-password' && ( + + setSharedPassword(event.target.value) + } + className='mt-3 block w-full rounded-md border border-gray-300 bg-background p-2 text-foreground shadow-sm' + placeholder='Password' + /> + )} +
+ )} + +
+ {items.map((item) => { + const progress = item.progress?.percentage ?? 0; + return ( +
+
+
+

+ {shortenString(itemName(item), 48)} +

+

+ {item.cid} +

+
+ + {statusLabel[item.status]} + +
+ {item.status === 'downloading' && ( +
+
+
+ )} + {item.progress?.downloadedBytes !== undefined && + item.status === 'downloading' && ( +

+ {formatBytes(item.progress.downloadedBytes)} /{' '} + {formatBytes( + item.progress.totalBytes ?? + Number( + item.information?.metadata.totalSize ?? 0, + ), + )} +

+ )} + {(item.error || item.skippedReason) && ( +

+ {item.error ?? item.skippedReason} +

+ )} +
+ ); + })} +
+ +
+ + {isComplete && failedCount > 0 && !isRunning && ( + + )} + +
+ + +
+
+
+
+ ); +}; diff --git a/apps/frontend/src/components/molecules/ObjectDownloadModal.tsx b/apps/frontend/src/components/molecules/ObjectDownloadModal.tsx index e5e98070..f7c0f457 100644 --- a/apps/frontend/src/components/molecules/ObjectDownloadModal.tsx +++ b/apps/frontend/src/components/molecules/ObjectDownloadModal.tsx @@ -24,13 +24,14 @@ import { mapObjectInformationFromQueryResult } from 'services/gql/utils'; import { useNetwork } from 'contexts/network'; import { DownloadProgressInfo } from 'services/download'; import { formatBytes } from 'utils/number'; -import { AsyncDownloadStatus, DownloadStatus } from '@auto-drive/models'; -import { getAuthSession } from '@/utils/auth'; import { useUserAsyncDownloadsStore } from '../organisms/UserAsyncDownloads/state'; +import { + ObjectDownloadAbortedError, + ObjectDownloadPhase, + runObjectDownloadFlow, +} from 'services/objectDownloadFlow'; const toastId = 'object-download-modal'; -const MAX_ASYNC_POLL_COUNT = 60; -const ASYNC_POLL_INTERVAL_MS = 10_000; export const ObjectDownloadModal = ({ cid, @@ -51,8 +52,6 @@ export const ObjectDownloadModal = ({ const [downloadError, setDownloadError] = useState(null); const [checkingStatus, setCheckingStatus] = useState(false); const [asyncPreparing, setAsyncPreparing] = useState(false); - const asyncPollRef = useRef | null>(null); - const pollCancelledRef = useRef(false); const defaultPassword = useEncryptionStore((store) => store.password); const network = useNetwork(); const updateAsyncDownloads = useUserAsyncDownloadsStore((e) => e.update); @@ -61,8 +60,8 @@ export const ObjectDownloadModal = ({ ); const downloadInitiatedRef = useRef(null); - const startSyncDownloadRef = useRef<() => Promise>(() => Promise.resolve()); const downloadAbortRef = useRef(null); + const downloadPhaseRef = useRef(null); const handleCloseWhileAsyncPreparing = useCallback(() => { if (!asyncPreparing || !metadata) return; @@ -99,16 +98,12 @@ export const ObjectDownloadModal = ({ setCheckingStatus(false); setAsyncPreparing(false); downloadInitiatedRef.current = null; + downloadPhaseRef.current = null; } return () => { downloadAbortRef.current?.abort(); downloadAbortRef.current = null; - pollCancelledRef.current = true; - if (asyncPollRef.current) { - clearTimeout(asyncPollRef.current); - asyncPollRef.current = null; - } }; }, [cid]); @@ -137,20 +132,48 @@ export const ObjectDownloadModal = ({ }, }); - const startSyncDownload = useCallback(async () => { + const onDownload = useCallback(async () => { if (!metadata) return; - const passwordToUse = skipDecryption ? undefined : password; + + downloadAbortRef.current?.abort(); + const abortController = new AbortController(); + downloadAbortRef.current = abortController; setDownloadError(null); setDownloadProgress(null); + downloadPhaseRef.current = null; try { - await network.downloadService.fetchFile(metadata.dataCid, { - password: passwordToUse, + await runObjectDownloadFlow({ + api: network.api, + downloadService: network.downloadService, + metadata, + password, skipDecryption, + signal: abortController.signal, onProgress: (progress) => { setDownloadProgress(progress); }, + onAsyncDownloadsRefresh: updateAsyncDownloads, + getAsyncDownloads: () => + useUserAsyncDownloadsStore.getState().asyncDownloads, + onPhaseChange: (phase) => { + const previousPhase = downloadPhaseRef.current; + downloadPhaseRef.current = phase; + + setCheckingStatus(phase === 'checking'); + setAsyncPreparing(phase === 'preparing'); + if (phase === 'downloading') { + setIsDownloading(true); + } + + if (phase === 'downloading' && previousPhase === 'preparing') { + toast.success( + `${shortenString(metadata.name ?? 'File', 30)} is ready — downloading now`, + { id: toastId }, + ); + } + }, }); await new Promise((resolve) => setTimeout(resolve, 250)); toast.success( @@ -166,6 +189,8 @@ export const ObjectDownloadModal = ({ setWrongPassword(true); setIsDownloading(false); downloadInitiatedRef.current = null; + } else if (e instanceof ObjectDownloadAbortedError) { + return; } else { console.error('Download failed:', e); const errorMessage = @@ -175,127 +200,19 @@ export const ObjectDownloadModal = ({ setDownloadError(errorMessage); toast.error(errorMessage, { id: toastId }); setIsDownloading(false); + setAsyncPreparing(false); + setCheckingStatus(false); } } - }, [metadata, password, skipDecryption, network.downloadService, onClose]); - - startSyncDownloadRef.current = startSyncDownload; - - const startAsyncDownloadAndPoll = useCallback(async () => { - if (!metadata) return; - - setIsDownloading(false); - setAsyncPreparing(true); - try { - await network.api.createAsyncDownload(metadata.dataCid); - updateAsyncDownloads(); - } catch (e) { - console.error('Failed to create async download:', e); - // Fall back to sync download if async creation fails - setAsyncPreparing(false); - setIsDownloading(true); - startSyncDownloadRef.current(); - return; - } - - pollCancelledRef.current = false; - let pollCount = 0; - const schedulePoll = () => { - asyncPollRef.current = setTimeout(async () => { - if (pollCancelledRef.current) return; - pollCount++; - try { - const status = await network.api.checkDownloadStatus( - metadata.dataCid, - ); - if (pollCancelledRef.current) return; - updateAsyncDownloads(); - if (status === DownloadStatus.Cached) { - asyncPollRef.current = null; - setAsyncPreparing(false); - setIsDownloading(true); - - toast.success( - `${shortenString(metadata.name ?? 'File', 30)} is ready — downloading now`, - { id: toastId }, - ); - startSyncDownloadRef.current(); - return; - } - - const asyncDownloads = - useUserAsyncDownloadsStore.getState().asyncDownloads; - const matchingDownload = asyncDownloads.find( - (d) => d.cid === metadata.dataCid, - ); - if ( - matchingDownload && - (matchingDownload.status === AsyncDownloadStatus.Failed || - matchingDownload.status === AsyncDownloadStatus.Dismissed) - ) { - asyncPollRef.current = null; - setAsyncPreparing(false); - const errorMsg = - matchingDownload.errorMessage || - 'Download failed on the server. Please try again.'; - setDownloadError(errorMsg); - toast.error(errorMsg, { id: toastId }); - return; - } - - if (pollCount >= MAX_ASYNC_POLL_COUNT) { - asyncPollRef.current = null; - setAsyncPreparing(false); - const errorMsg = - 'Download preparation timed out. Please try again later.'; - setDownloadError(errorMsg); - toast.error(errorMsg, { id: toastId }); - return; - } - } catch { - // Ignore transient poll errors - } - if (!pollCancelledRef.current) { - schedulePoll(); - } - }, ASYNC_POLL_INTERVAL_MS); - }; - schedulePoll(); - }, [metadata, network.api, updateAsyncDownloads]); - - const onDownload = useCallback(async () => { - if (!metadata || asyncPreparing) return; - - downloadAbortRef.current?.abort(); - const abortController = new AbortController(); - downloadAbortRef.current = abortController; - const { signal } = abortController; - - setCheckingStatus(true); - - try { - const session = await getAuthSession().catch(() => null); - if (signal.aborted) return; - const hasSession = !!session?.accessToken && !!session?.authProvider; - - if (hasSession) { - const status = await network.api.checkDownloadStatus(metadata.dataCid); - if (signal.aborted) return; - if (status === DownloadStatus.NotCached) { - setCheckingStatus(false); - startAsyncDownloadAndPoll(); - return; - } - } - } catch { - if (signal.aborted) return; - } - - if (signal.aborted) return; - setCheckingStatus(false); - setIsDownloading(true); - startSyncDownload(); - }, [metadata, network.api, startSyncDownload, startAsyncDownloadAndPoll, asyncPreparing]); + }, [ + metadata, + password, + skipDecryption, + network.api, + network.downloadService, + updateAsyncDownloads, + onClose, + ]); const passwordOrNotEncrypted = (metadata && !metadata.uploadOptions?.encryption?.algorithm) || @@ -559,7 +476,8 @@ export const ObjectDownloadModal = ({ ]); // Show modal when there's a view to display OR when downloading/preparing - const shouldShowModal = !!cid && (!!view || isDownloading || asyncPreparing || checkingStatus); + const shouldShowModal = + !!cid && (!!view || isDownloading || asyncPreparing || checkingStatus); if (!shouldShowModal) return <>; @@ -600,7 +518,7 @@ export const ObjectDownloadModal = ({ leaveFrom='opacity-100 scale-100' leaveTo='opacity-0 scale-95' > - + {view ?? progressView} diff --git a/apps/frontend/src/components/organisms/FileTable/index.tsx b/apps/frontend/src/components/organisms/FileTable/index.tsx index de485694..758f49b3 100644 --- a/apps/frontend/src/components/organisms/FileTable/index.tsx +++ b/apps/frontend/src/components/organisms/FileTable/index.tsx @@ -6,6 +6,7 @@ import { FC, useCallback, useState } from 'react'; import { ObjectShareModal } from '@/components/molecules/ObjectShareModal'; import { ObjectDeleteModal } from '@/components/molecules/ObjectDeleteModal'; import { ObjectDownloadModal } from '@/components/molecules/ObjectDownloadModal'; +import { BulkObjectDownloadModal } from '@/components/molecules/BulkObjectDownloadModal'; import { useUserStore } from 'globalStates/user'; import { Table } from '@/components/molecules/Table'; import { @@ -53,6 +54,7 @@ export const FileTable: FC<{ const [deleteCID, setDeleteCID] = useState(null); const [reportCID, setReportCID] = useState(null); const [selectedFiles, setSelectedFiles] = useState([]); + const [isBulkDownloadOpen, setIsBulkDownloadOpen] = useState(false); const objects = useFileTableState((v) => v.objects); const refetch = useFileTableState((v) => v.fetch); @@ -87,6 +89,12 @@ export const FileTable: FC<{ setShareCID(null)} /> + setIsBulkDownloadOpen(false)} + onComplete={() => setSelectedFiles([])} + />
@@ -120,7 +128,11 @@ export const FileTable: FC<{ {selectedFiles.length} files selected -
diff --git a/apps/frontend/src/services/bulkObjectDownload.ts b/apps/frontend/src/services/bulkObjectDownload.ts new file mode 100644 index 00000000..0caccc6c --- /dev/null +++ b/apps/frontend/src/services/bulkObjectDownload.ts @@ -0,0 +1,86 @@ +import { isInsecure, ObjectInformation } from '@auto-drive/models'; +import { DownloadProgressInfo } from 'services/download'; + +export type BulkDownloadStatus = + | 'pending' + | 'skipped' + | 'checking' + | 'preparing' + | 'downloading' + | 'completed' + | 'failed'; + +export type EncryptionChoice = + | 'download-encrypted' + | 'shared-password' + | 'skip'; + +export interface BulkDownloadItem { + cid: string; + information?: ObjectInformation; + status: BulkDownloadStatus; + progress?: DownloadProgressInfo | null; + error?: string; + skippedReason?: string; +} + +export interface EncryptionContext { + defaultPassword?: string | null; + encryptionChoice: EncryptionChoice | null; + sharedPassword: string; +} + +export interface ResolvedEncryptionOptions { + password: string | undefined; + skipDecryption: boolean; +} + +export const hasEncryption = (item: BulkDownloadItem): boolean => + !!item.information?.metadata.uploadOptions?.encryption?.algorithm; + +// Items that are eligible to run in the queue. Insecure items pass this +// predicate but are gated separately at runtime (see `shouldSkipInsecure`) +// so the user's confirmation toggle isn't bypassed. +export const itemIsRunnable = ( + item: BulkDownloadItem, +): item is BulkDownloadItem & { information: ObjectInformation } => + item.status === 'pending' && !!item.information; + +export const shouldSkipInsecure = ( + item: BulkDownloadItem, + hasConfirmedInsecure: boolean, +): boolean => { + if (!item.information) return false; + return isInsecure(item.information.tags) && !hasConfirmedInsecure; +}; + +export const shouldSkipEncrypted = ( + item: BulkDownloadItem, + context: EncryptionContext, +): boolean => + hasEncryption(item) && + !context.defaultPassword && + context.encryptionChoice === 'skip'; + +export const resolveEncryptionOptions = ( + item: BulkDownloadItem, + context: EncryptionContext, +): ResolvedEncryptionOptions => { + if (!hasEncryption(item)) { + return { password: undefined, skipDecryption: false }; + } + + if (context.defaultPassword) { + return { password: context.defaultPassword, skipDecryption: false }; + } + + if (context.encryptionChoice === 'download-encrypted') { + return { password: undefined, skipDecryption: true }; + } + + if (context.encryptionChoice === 'shared-password') { + return { password: context.sharedPassword, skipDecryption: false }; + } + + return { password: undefined, skipDecryption: false }; +}; diff --git a/apps/frontend/src/services/objectDownloadFlow.ts b/apps/frontend/src/services/objectDownloadFlow.ts new file mode 100644 index 00000000..d5b4432e --- /dev/null +++ b/apps/frontend/src/services/objectDownloadFlow.ts @@ -0,0 +1,182 @@ +import { OffchainMetadata } from '@autonomys/auto-dag-data'; +import { AsyncDownloadStatus, DownloadStatus } from '@auto-drive/models'; +import { Api } from 'services/api'; +import { DownloadApi, DownloadOptions } from 'services/download'; +import { getAuthSession } from '@/utils/auth'; + +const MAX_ASYNC_POLL_COUNT = 60; +const ASYNC_POLL_INTERVAL_MS = 10_000; + +export type ObjectDownloadPhase = + | 'checking' + | 'preparing' + | 'downloading' + | 'completed'; + +export class ObjectDownloadAbortedError extends Error { + constructor() { + super('Download aborted'); + } +} + +class ObjectDownloadPreparationError extends Error {} + +export interface ObjectDownloadFlowOptions { + api: Api; + downloadService: DownloadApi; + metadata: OffchainMetadata; + password?: string; + skipDecryption?: boolean; + signal?: AbortSignal; + onProgress?: DownloadOptions['onProgress']; + onPhaseChange?: (phase: ObjectDownloadPhase) => void; + onAsyncDownloadsRefresh?: () => void; + getAsyncDownloads?: () => { + cid: string; + status: AsyncDownloadStatus; + errorMessage?: string | null; + }[]; + maxAsyncPollCount?: number; + asyncPollIntervalMs?: number; +} + +const assertNotAborted = (signal?: AbortSignal) => { + if (signal?.aborted) { + throw new ObjectDownloadAbortedError(); + } +}; + +const delay = (ms: number, signal?: AbortSignal) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new ObjectDownloadAbortedError()); + return; + } + + const timeout = setTimeout(resolve, ms); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timeout); + reject(new ObjectDownloadAbortedError()); + }, + { once: true }, + ); + }); + +export const runObjectDownloadFlow = async ({ + api, + downloadService, + metadata, + password, + skipDecryption = false, + signal, + onProgress, + onPhaseChange, + onAsyncDownloadsRefresh, + getAsyncDownloads, + maxAsyncPollCount = MAX_ASYNC_POLL_COUNT, + asyncPollIntervalMs = ASYNC_POLL_INTERVAL_MS, +}: ObjectDownloadFlowOptions) => { + assertNotAborted(signal); + onPhaseChange?.('checking'); + + let shouldPrepareAsync = false; + try { + const session = await getAuthSession().catch(() => null); + assertNotAborted(signal); + const hasSession = !!session?.accessToken && !!session?.authProvider; + + if (hasSession) { + const status = await api.checkDownloadStatus(metadata.dataCid); + assertNotAborted(signal); + shouldPrepareAsync = status === DownloadStatus.NotCached; + } + } catch (error) { + if (error instanceof ObjectDownloadAbortedError) { + throw error; + } + shouldPrepareAsync = false; + } + + if (shouldPrepareAsync) { + onPhaseChange?.('preparing'); + try { + await api.createAsyncDownload(metadata.dataCid); + onAsyncDownloadsRefresh?.(); + } catch (error) { + if (error instanceof ObjectDownloadAbortedError) { + throw error; + } + shouldPrepareAsync = false; + onPhaseChange?.('checking'); + } + } + + if (shouldPrepareAsync) { + for (let pollCount = 0; pollCount < maxAsyncPollCount; pollCount++) { + await delay(asyncPollIntervalMs, signal); + assertNotAborted(signal); + + let isCached = false; + try { + const status = await api.checkDownloadStatus(metadata.dataCid); + onAsyncDownloadsRefresh?.(); + assertNotAborted(signal); + if (status === DownloadStatus.Cached) { + isCached = true; + } else { + const matchingDownload = getAsyncDownloads?.().find( + (d) => d.cid === metadata.dataCid, + ); + if ( + matchingDownload && + (matchingDownload.status === AsyncDownloadStatus.Failed || + matchingDownload.status === AsyncDownloadStatus.Dismissed) + ) { + throw new ObjectDownloadPreparationError( + matchingDownload.errorMessage || + 'Download failed on the server. Please try again.', + ); + } + } + } catch (error) { + if (error instanceof ObjectDownloadAbortedError) { + throw error; + } + + if (error instanceof ObjectDownloadPreparationError) { + throw error; + } + + // Transient poll-cycle failure (e.g. network blip). Don't fail the + // whole flow — we'll retry next cycle or hit the timeout. Surface + // for debugging so a fully broken gateway doesn't fail silently. + console.warn( + `[objectDownloadFlow] poll cycle ${pollCount + 1}/${maxAsyncPollCount} failed for ${metadata.dataCid}; continuing`, + error, + ); + } + + if (isCached) { + break; + } + + if (pollCount === maxAsyncPollCount - 1) { + throw new ObjectDownloadPreparationError( + 'Download preparation timed out. Please try again later.', + ); + } + } + } + + assertNotAborted(signal); + onPhaseChange?.('downloading'); + await downloadService.fetchFile(metadata.dataCid, { + password: skipDecryption ? undefined : password, + skipDecryption, + onProgress, + }); + assertNotAborted(signal); + onPhaseChange?.('completed'); +};