Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/app/network/NetworkFacade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be called with the overrideKey?

@terrerox terrerox Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think so, because that's the purpose of the test: to check if the derived key is overridden by the override key. If we changed that line to the override key, it would be checking the same key — overrideKey === overrideKey.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You added the test because you added the key override to decryptStream. Why was override added to decryptStream?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answer this @terrerox pls

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sg-gs This PR was clashing with changes I made for new private sharings. So, I've incorporated multi-part downloads into new private sharings: #2116

We can close this PR

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P.S. It's working, and server changes were already merged, but QA was postponed due to Mail & Mobile

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You added the test because you added the key override to decryptStream. Why was override added to decryptStream?

So shared files can be decrypted in multipart downloads.

Someone opening a shared link doesn't have the owner's mnemonic, so the key the SDK works out is wrong. The share gives us the file's real key as options.key, and the override tells decryptStream to use it. download() already did this. downloadChunk() didn't, so large shared files would have downloaded corrupted.

},
);

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,
Expand Down
2 changes: 1 addition & 1 deletion src/app/network/NetworkFacade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
42 changes: 42 additions & 0 deletions src/app/network/download/v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
124 changes: 61 additions & 63 deletions src/app/network/download/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,29 @@
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 =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's easier to remove the shared download from the network than create an empty auth

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That empty auth was already there before this PR tbh, downloadSharedFile on master did the same thing, I just moved it into createNetworkFacade. And we can't really avoid it, the SDK client requires those fields and for shared files the backend only gives us the key + token, no network creds. The token is what authorizes the download so the auth is ignored anyway.

new NetworkFacade(
Network.client(
envService.getVariable('storjBridge'),
{
clientName: 'drive-web',
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,
Expand All @@ -82,19 +89,7 @@
} = 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,
});
Expand All @@ -109,25 +104,13 @@
} = 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<FileStream> {
const {
Expand All @@ -139,34 +122,61 @@
} = 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(

Check warning on line 142 in src/app/network/download/v2.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Async function 'multipartDownloadSharedFile' has no 'await' expression.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-web&issues=AaED-boTg7hdaRA9wIRB&open=AaED-boTg7hdaRA9wIRB&pullRequest=2040
params: DownloadSharedFileParams & { fileSize: number },
): Promise<FileStream> {
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<FileStream> {
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<FileStream> {
Expand All @@ -180,19 +190,7 @@
} = 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,
Expand Down
4 changes: 2 additions & 2 deletions src/app/network/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
13 changes: 11 additions & 2 deletions src/views/PublicShared/ShareFileView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -208,9 +210,11 @@ export default function ShareFileView(props: Readonly<ShareViewProps>): 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'),
Expand All @@ -224,7 +228,12 @@ export default function ShareFileView(props: Readonly<ShareViewProps>): 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);
}

Expand Down
Loading