From fdd28c867dacefc3769eff2a34468bce69be9d9a Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Mon, 13 Jul 2026 16:17:29 +0200 Subject: [PATCH 1/2] feat: add multipart download for shared files --- src/app/network/NetworkFacade.test.ts | 27 ++++++++++++++ src/app/network/NetworkFacade.ts | 7 +++- src/app/network/download/v2.test.ts | 42 +++++++++++++++++++++ src/app/network/download/v2.ts | 47 +++++++++++++++++++++++- src/app/network/index.ts | 4 +- src/views/PublicShared/ShareFileView.tsx | 17 +++++++-- 6 files changed, 136 insertions(+), 8 deletions(-) diff --git a/src/app/network/NetworkFacade.test.ts b/src/app/network/NetworkFacade.test.ts index b2fc115e7f..bcb0fa7195 100644 --- a/src/app/network/NetworkFacade.test.ts +++ b/src/app/network/NetworkFacade.test.ts @@ -202,6 +202,33 @@ describe('NetworkFacade', () => { ); }); + test('When options.key is provided, it overrides the SDK-provided key', async () => { + const mockStream = new ReadableStream(); + const mockResponse = { status: 206, body: mockStream }; + const sdkDerivedKey = Buffer.alloc(32, 0x01); + const overrideKey = Buffer.alloc(32, 0x02); + const iv = Buffer.alloc(16); + + (global.fetch as any).mockResolvedValue(mockResponse); + (downloadFile as any).mockImplementation( + async (fileId, bucketId, mnemonic, network, cryptoLib, bufferFrom, downloadCallback, decryptCallback) => { + await downloadCallback([{ url: downloadExample }]); + await decryptCallback('aes256ctr', sdkDerivedKey, iv, 2048); + }, + ); + + await networkFacade.downloadChunk({ + bucketId, + fileId, + mnemonic, + chunkStart, + chunkEnd, + options: { key: overrideKey }, + }); + + expect(decryptStream).toHaveBeenCalledWith([mockStream], overrideKey, iv, chunkStart); + }); + it('When the status is not 200 or 206 (successfully downloaded), then an error indicating so is thrown', async () => { const mockResponse = { status: 404, diff --git a/src/app/network/NetworkFacade.ts b/src/app/network/NetworkFacade.ts index 00bc91f634..e9111b99e0 100644 --- a/src/app/network/NetworkFacade.ts +++ b/src/app/network/NetworkFacade.ts @@ -415,7 +415,12 @@ export class NetworkFacade { } }, async (algorithm, key, iv, fileSize) => { - fileStream = decryptStream(encryptedContentStreams, key as Buffer, iv as Buffer, chunkStart); + fileStream = decryptStream( + encryptedContentStreams, + (options?.key || key) as Buffer, + iv as Buffer, + chunkStart, + ); }, (options?.token && { token: options.token }) || undefined, ); diff --git a/src/app/network/download/v2.test.ts b/src/app/network/download/v2.test.ts index 238b5c170c..9bc124c455 100644 --- a/src/app/network/download/v2.test.ts +++ b/src/app/network/download/v2.test.ts @@ -74,6 +74,48 @@ describe('Download V2', () => { }); expect(result).toStrictEqual(mockStream); }); + + test('When token and encryptionKey are provided, the shared facade is created with empty auth and the encryptionKey is forwarded as options.key', async () => { + const abortController = new AbortController(); + const progressCallback = vi.fn(); + const encryptionKey = 'aabbccdd'; + const token = 'shared-token'; + + const networkClientSpy = vi.spyOn(Network, 'client').mockReturnValue(mockNetworkClient as any); + const downloadFileSpy = vi.spyOn(MultipartDownload.prototype, 'downloadFile').mockResolvedValue(mockStream); + + const result = await multipartDownload({ + bucketId: 'test-bucket', + fileId: 'test-file', + key: {} as any, + token, + encryptionKey, + fileSize: 2048, + options: { + notifyProgress: progressCallback, + abortController, + }, + } as any); + + expect(networkClientSpy).toHaveBeenCalledWith( + mockBridgeUrl, + { clientName: 'drive-web', clientVersion: '1.0' }, + { bridgeUser: '', userId: '' }, + ); + expect(downloadFileSpy).toHaveBeenCalledWith({ + bucketId: 'test-bucket', + fileId: 'test-file', + mnemonic: '', + fileSize: 2048, + options: { + key: Buffer.from(encryptionKey, 'hex'), + token, + downloadingCallback: progressCallback, + abortController, + }, + }); + expect(result).toStrictEqual(mockStream); + }); }); describe('Download single chunk', () => { diff --git a/src/app/network/download/v2.ts b/src/app/network/download/v2.ts index d9b5a2bae4..1f88cf7215 100644 --- a/src/app/network/download/v2.ts +++ b/src/app/network/download/v2.ts @@ -126,7 +126,7 @@ const downloadOwnFileWithBucketKey = async (params: DownloadOwnFileWithBucketKey }); }; -export async function multipartDownload( +async function multipartDownloadOwnFile( params: DownloadOwnFileWithMnemonicParams & { fileSize: number }, ): Promise { const { @@ -166,6 +166,51 @@ export async function multipartDownload( }); } +async function multipartDownloadSharedFile( + params: DownloadSharedFileParams & { fileSize: number }, +): Promise { + const { bucketId, fileId, encryptionKey, token, fileSize, options } = params; + + const networkFacade = new NetworkFacade( + Network.client( + envService.getVariable('storjBridge'), + { + clientName: 'drive-web', + clientVersion: '1.0', + }, + { + bridgeUser: '', + userId: '', + }, + ), + ); + + const multipartDownload = new MultipartDownload(networkFacade); + + return multipartDownload.downloadFile({ + bucketId, + fileId, + mnemonic: '', + fileSize, + options: { + key: Buffer.from(encryptionKey, 'hex'), + token, + downloadingCallback: options?.notifyProgress, + abortController: options?.abortController, + }, + }); +} + +export async function multipartDownload( + params: (DownloadOwnFileWithMnemonicParams | DownloadSharedFileParams) & { fileSize: number }, +): Promise { + if (params.token && params.encryptionKey) { + return multipartDownloadSharedFile(params); + } + + return multipartDownloadOwnFile(params as DownloadOwnFileWithMnemonicParams & { fileSize: number }); +} + export async function downloadChunkFile( params: DownloadOwnFileWithMnemonicParams & { chunkStart: number; chunkEnd: number }, ): Promise { diff --git a/src/app/network/index.ts b/src/app/network/index.ts index 4236e7f459..c31cb659a0 100644 --- a/src/app/network/index.ts +++ b/src/app/network/index.ts @@ -1,4 +1,4 @@ import { uploadFile } from './upload'; -import { downloadFile } from './download'; +import { downloadFile, multipartDownloadFile } from './download'; -export default { uploadFile, downloadFile }; +export default { uploadFile, downloadFile, multipartDownloadFile }; diff --git a/src/views/PublicShared/ShareFileView.tsx b/src/views/PublicShared/ShareFileView.tsx index 04893ba07c..579bd0f97a 100644 --- a/src/views/PublicShared/ShareFileView.tsx +++ b/src/views/PublicShared/ShareFileView.tsx @@ -16,6 +16,8 @@ import UilArrowRight from '@iconscout/react-unicons/icons/uil-arrow-right'; import { Check, DownloadSimple, Eye } from '@phosphor-icons/react'; import downloadService from 'app/drive/services/download.service'; +import { MIN_DOWNLOAD_MULTIPART_SIZE } from 'app/network/networkConstants'; +import { DownloadProgressCallback } from 'app/network/download'; import './components/ShareView.scss'; import { ShareTypes } from '@internxt/sdk/dist/drive'; @@ -195,24 +197,31 @@ export default function ShareFileView(props: Readonly): JSX.Elem if (fileInfo) { const encryptionKey = fileInfo.encryptionKey; + const fileSize = fileInfo.item.size; setProgress(MIN_PROGRESS); setIsDownloading(true); - const readable = await network.downloadFile({ + + const downloadParams = { bucketId: fileInfo.item.bucket, fileId: fileInfo.item.fileId, encryptionKey: Buffer.from(encryptionKey, 'hex'), token: fileInfo.itemToken, options: { - notifyProgress: (totalProgress, downloadedBytes) => { + notifyProgress: ((totalProgress, downloadedBytes) => { const progress = Math.trunc((downloadedBytes / totalProgress) * 100); setProgress(progress); if (progress == 100) { setIsDownloading(false); } - }, + }) as DownloadProgressCallback, }, - }); + }; + + const readable = + fileSize >= MIN_DOWNLOAD_MULTIPART_SIZE + ? await network.multipartDownloadFile({ ...downloadParams, fileSize }) + : await network.downloadFile(downloadParams); const fileBlob = await binaryStreamToBlob(readable); await downloadService.downloadFileFromBlob(fileBlob, getFormatFileName()); From 2471c96e04116075c49db507bac9f16ee7d9f89d Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sun, 16 Aug 2026 15:29:33 -0400 Subject: [PATCH 2/2] feat: implement multipart download for shared files and refactor download logic --- src/app/network/download/v2.ts | 121 +++++++---------------- src/views/PublicShared/ShareFileView.tsx | 16 +-- 2 files changed, 45 insertions(+), 92 deletions(-) diff --git a/src/app/network/download/v2.ts b/src/app/network/download/v2.ts index 1f88cf7215..866fedb5cb 100644 --- a/src/app/network/download/v2.ts +++ b/src/app/network/download/v2.ts @@ -42,10 +42,12 @@ type DownloadFileFunction = ( params: DownloadSharedFileParams | DownloadOwnFileWithMnemonicParams | DownloadOwnFileWithBucketKeyParams, ) => DownloadFileResponse; -const downloadSharedFile: DownloadSharedFileFunction = (params) => { - const { bucketId, fileId, encryptionKey, token, options } = params; - - return new NetworkFacade( +/** + * Creates a NetworkFacade for the bridge. Pass auth for downloads of the user's own files; + * omit it for shared-link downloads, which authenticate through the share token instead. + */ +const createNetworkFacade = (auth?: { username: string; password: string }): NetworkFacade => + new NetworkFacade( Network.client( envService.getVariable('storjBridge'), { @@ -53,11 +55,16 @@ const downloadSharedFile: DownloadSharedFileFunction = (params) => { clientVersion: '1.0', }, { - bridgeUser: '', - userId: '', + bridgeUser: auth?.username ?? '', + userId: auth?.password ?? '', }, ), - ).download(bucketId, fileId, '', { + ); + +const downloadSharedFile: DownloadSharedFileFunction = (params) => { + const { bucketId, fileId, encryptionKey, token, options } = params; + + return createNetworkFacade().download(bucketId, fileId, '', { key: Buffer.from(encryptionKey, 'hex'), token, downloadingCallback: options?.notifyProgress, @@ -81,19 +88,7 @@ const downloadOwnFile = async (params: DownloadOwnFileWithMnemonicParams) => { } = params; const auth = await getAuthFromCredentials(params.creds); - return new NetworkFacade( - Network.client( - envService.getVariable('storjBridge'), - { - clientName: 'drive-web', - clientVersion: '1.0', - }, - { - bridgeUser: auth.username, - userId: auth.password, - }, - ), - ).download(bucketId, fileId, mnemonic, { + return createNetworkFacade(auth).download(bucketId, fileId, mnemonic, { downloadingCallback: options?.notifyProgress, abortController: options?.abortController, }); @@ -108,19 +103,7 @@ const downloadOwnFileWithBucketKey = async (params: DownloadOwnFileWithBucketKey } = params; const auth = await getAuthFromCredentials(params.creds); - return new NetworkFacade( - Network.client( - envService.getVariable('storjBridge'), - { - clientName: 'drive-web', - clientVersion: '1.0', - }, - { - bridgeUser: auth.username, - userId: auth.password, - }, - ), - ).downloadWithBucketKey(bucketId, fileId, bucketKey, { + return createNetworkFacade(auth).downloadWithBucketKey(bucketId, fileId, bucketKey, { downloadingCallback: options?.notifyProgress, abortController: options?.abortController, }); @@ -138,23 +121,7 @@ async function multipartDownloadOwnFile( } = params; const auth = await getAuthFromCredentials(params.creds); - const networkFacade = new NetworkFacade( - Network.client( - envService.getVariable('storjBridge'), - { - clientName: 'drive-web', - clientVersion: '1.0', - }, - { - bridgeUser: auth.username, - userId: auth.password, - }, - ), - ); - - const multipartDownload = new MultipartDownload(networkFacade); - - return multipartDownload.downloadFile({ + return new MultipartDownload(createNetworkFacade(auth)).downloadFile({ bucketId, fileId, mnemonic, @@ -166,28 +133,17 @@ async function multipartDownloadOwnFile( }); } +/** + * Downloads a shared file in chunks. The mnemonic is intentionally empty: shared downloads + * authenticate through the share token, and options.key overrides the mnemonic-derived + * decryption key downstream in NetworkFacade.downloadChunk. + */ async function multipartDownloadSharedFile( params: DownloadSharedFileParams & { fileSize: number }, ): Promise { const { bucketId, fileId, encryptionKey, token, fileSize, options } = params; - const networkFacade = new NetworkFacade( - Network.client( - envService.getVariable('storjBridge'), - { - clientName: 'drive-web', - clientVersion: '1.0', - }, - { - bridgeUser: '', - userId: '', - }, - ), - ); - - const multipartDownload = new MultipartDownload(networkFacade); - - return multipartDownload.downloadFile({ + return new MultipartDownload(createNetworkFacade()).downloadFile({ bucketId, fileId, mnemonic: '', @@ -201,14 +157,23 @@ async function multipartDownloadSharedFile( }); } +/** + * Downloads a file in chunks, dispatching to the shared-link flow when the params carry + * a share token and encryption key, or to the own-file flow when they carry credentials. + */ export async function multipartDownload( params: (DownloadOwnFileWithMnemonicParams | DownloadSharedFileParams) & { fileSize: number }, ): Promise { - if (params.token && params.encryptionKey) { - return multipartDownloadSharedFile(params); - } + const isSharedDownload = Boolean(params.token && params.encryptionKey); + const isOwnFileDownload = Boolean(params.creds && params.key.mnemonic); - return multipartDownloadOwnFile(params as DownloadOwnFileWithMnemonicParams & { fileSize: number }); + if (isSharedDownload) { + return multipartDownloadSharedFile(params as DownloadSharedFileParams & { fileSize: number }); + } else if (isOwnFileDownload) { + return multipartDownloadOwnFile(params as DownloadOwnFileWithMnemonicParams & { fileSize: number }); + } else { + throw new Error('DOWNLOAD ERRNO. 0'); + } } export async function downloadChunkFile( @@ -224,19 +189,7 @@ export async function downloadChunkFile( } = params; const auth = await getAuthFromCredentials(params.creds); - return new NetworkFacade( - Network.client( - envService.getVariable('storjBridge'), - { - clientName: 'drive-web', - clientVersion: '1.0', - }, - { - bridgeUser: auth.username, - userId: auth.password, - }, - ), - ).downloadChunk({ + return createNetworkFacade(auth).downloadChunk({ bucketId, fileId, mnemonic, diff --git a/src/views/PublicShared/ShareFileView.tsx b/src/views/PublicShared/ShareFileView.tsx index 26e4b18b0e..e8afe4bab2 100644 --- a/src/views/PublicShared/ShareFileView.tsx +++ b/src/views/PublicShared/ShareFileView.tsx @@ -17,7 +17,7 @@ import { CheckIcon, DownloadSimpleIcon, EyeIcon } from '@phosphor-icons/react'; import downloadService from 'app/drive/services/download.service'; import { MIN_DOWNLOAD_MULTIPART_SIZE } from 'app/network/networkConstants'; -import { DownloadProgressCallback } from 'app/network/download'; +import { IDownloadParams } from 'app/network/download'; import './components/ShareView.scss'; import { ShareTypes } from '@internxt/sdk/dist/drive'; @@ -203,26 +203,26 @@ export default function ShareFileView(props: Readonly): JSX.Elem setProgress(MIN_PROGRESS); setIsDownloading(true); - const downloadParams = { + const downloadParams: IDownloadParams = { bucketId: fileInfo.item.bucket, fileId: fileInfo.item.fileId, encryptionKey: Buffer.from(encryptionKey, 'hex'), token: fileInfo.itemToken, options: { - notifyProgress: ((totalProgress, downloadedBytes) => { + notifyProgress: (totalProgress, downloadedBytes) => { const progress = Math.trunc((downloadedBytes / totalProgress) * 100); setProgress(progress); if (progress == 100) { setIsDownloading(false); } - }) as DownloadProgressCallback, + }, }, }; - const readable = - fileSize >= MIN_DOWNLOAD_MULTIPART_SIZE - ? await network.multipartDownloadFile({ ...downloadParams, fileSize }) - : await network.downloadFile(downloadParams); + const shouldUseMultipart = fileSize >= MIN_DOWNLOAD_MULTIPART_SIZE; + const readable = shouldUseMultipart + ? await network.multipartDownloadFile({ ...downloadParams, fileSize }) + : await network.downloadFile(downloadParams); const fileBlob = await binaryStreamToBlob(readable); await downloadService.downloadFileFromBlob(fileBlob, getFormatFileName());