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 72f0bd25a4..2ad5b0df65 100644 --- a/src/app/network/NetworkFacade.ts +++ b/src/app/network/NetworkFacade.ts @@ -413,7 +413,7 @@ 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 31e4224122..34414dff59 100644 --- a/src/app/network/download/v2.test.ts +++ b/src/app/network/download/v2.test.ts @@ -75,6 +75,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: packageJson.version }, + { 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 643585b6e8..b4afba030f 100644 --- a/src/app/network/download/v2.ts +++ b/src/app/network/download/v2.ts @@ -43,10 +43,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'), { @@ -54,11 +56,16 @@ const downloadSharedFile: DownloadSharedFileFunction = (params) => { clientVersion: packageJson.version, }, { - 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, @@ -82,19 +89,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: packageJson.version, - }, - { - bridgeUser: auth.username, - userId: auth.password, - }, - ), - ).download(bucketId, fileId, mnemonic, { + return createNetworkFacade(auth).download(bucketId, fileId, mnemonic, { downloadingCallback: options?.notifyProgress, abortController: options?.abortController, }); @@ -109,25 +104,13 @@ const downloadOwnFileWithBucketKey = async (params: DownloadOwnFileWithBucketKey } = params; const auth = await getAuthFromCredentials(params.creds); - return new NetworkFacade( - Network.client( - envService.getVariable('storjBridge'), - { - clientName: 'drive-web', - clientVersion: packageJson.version, - }, - { - bridgeUser: auth.username, - userId: auth.password, - }, - ), - ).downloadWithBucketKey(bucketId, fileId, bucketKey, { + return createNetworkFacade(auth).downloadWithBucketKey(bucketId, fileId, bucketKey, { downloadingCallback: options?.notifyProgress, abortController: options?.abortController, }); }; -export async function multipartDownload( +async function multipartDownloadOwnFile( params: DownloadOwnFileWithMnemonicParams & { fileSize: number }, ): Promise { const { @@ -139,34 +122,61 @@ export async function multipartDownload( } = params; const auth = await getAuthFromCredentials(params.creds); - const networkFacade = new NetworkFacade( - Network.client( - envService.getVariable('storjBridge'), - { - clientName: 'drive-web', - clientVersion: packageJson.version, - }, - { - bridgeUser: auth.username, - userId: auth.password, - }, - ), - ); + return new MultipartDownload(createNetworkFacade(auth)).downloadFile({ + bucketId, + fileId, + mnemonic, + fileSize, + options: { + downloadingCallback: options?.notifyProgress, + abortController: options?.abortController, + }, + }); +} - const multipartDownload = new MultipartDownload(networkFacade); +/** + * 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; - return multipartDownload.downloadFile({ + return new MultipartDownload(createNetworkFacade()).downloadFile({ bucketId, fileId, - mnemonic, + mnemonic: '', fileSize, options: { + key: Buffer.from(encryptionKey, 'hex'), + token, downloadingCallback: options?.notifyProgress, abortController: options?.abortController, }, }); } +/** + * 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 { + const isSharedDownload = Boolean(params.token && params.encryptionKey); + const isOwnFileDownload = Boolean(params.creds && params.key.mnemonic); + + 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( params: DownloadOwnFileWithMnemonicParams & { chunkStart: number; chunkEnd: number }, ): Promise { @@ -180,19 +190,7 @@ export async function downloadChunkFile( } = params; const auth = await getAuthFromCredentials(params.creds); - return new NetworkFacade( - Network.client( - envService.getVariable('storjBridge'), - { - clientName: 'drive-web', - clientVersion: packageJson.version, - }, - { - bridgeUser: auth.username, - userId: auth.password, - }, - ), - ).downloadChunk({ + return createNetworkFacade(auth).downloadChunk({ bucketId, fileId, mnemonic, 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 dd3bfc8192..9d1c8726c6 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 { 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 { IDownloadParams } from 'app/network/download'; import './components/ShareView.scss'; import { ShareTypes } from '@internxt/sdk/dist/drive'; @@ -208,9 +210,11 @@ export default function ShareFileView(props: Readonly): JSX.Elem setProgress(100 as TaskProgress); fileBlob = new Blob([]); } else { + const fileSize = fileInfo.item.size; setProgress(MIN_PROGRESS); setIsDownloading(true); - const readable = await network.downloadFile({ + + const downloadParams: IDownloadParams = { bucketId: fileInfo.item.bucket, fileId: fileInfo.item.fileId, encryptionKey: Buffer.from(fileInfo.encryptionKey, 'hex'), @@ -224,7 +228,12 @@ export default function ShareFileView(props: Readonly): JSX.Elem } }, }, - }); + }; + + const shouldUseMultipart = fileSize >= MIN_DOWNLOAD_MULTIPART_SIZE; + const readable = shouldUseMultipart + ? await network.multipartDownloadFile({ ...downloadParams, fileSize }) + : await network.downloadFile(downloadParams); fileBlob = await binaryStreamToBlob(readable); }