@protontech/drive-sdk 0.14.4 → 0.14.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/interface/account.d.ts +2 -1
- package/dist/internal/batchLoading.js +3 -0
- package/dist/internal/batchLoading.js.map +1 -1
- package/dist/internal/batchLoading.test.js +27 -0
- package/dist/internal/batchLoading.test.js.map +1 -1
- package/dist/internal/nodes/cryptoService.d.ts +3 -2
- package/dist/internal/nodes/cryptoService.js +11 -1
- package/dist/internal/nodes/cryptoService.js.map +1 -1
- package/dist/internal/nodes/cryptoService.test.js +40 -2
- package/dist/internal/nodes/cryptoService.test.js.map +1 -1
- package/dist/internal/nodes/index.js +1 -1
- package/dist/internal/nodes/index.js.map +1 -1
- package/dist/internal/nodes/nodesManagement.d.ts +2 -2
- package/dist/internal/nodes/nodesManagement.js +1 -1
- package/dist/internal/nodes/nodesManagement.js.map +1 -1
- package/dist/internal/nodes/nodesManagement.test.js +30 -0
- package/dist/internal/nodes/nodesManagement.test.js.map +1 -1
- package/dist/internal/photos/index.js +1 -1
- package/dist/internal/photos/index.js.map +1 -1
- package/dist/internal/photos/nodes.js +14 -1
- package/dist/internal/photos/nodes.js.map +1 -1
- package/dist/internal/photos/nodes.test.js +44 -0
- package/dist/internal/photos/nodes.test.js.map +1 -1
- package/dist/internal/sharing/apiService.d.ts +1 -1
- package/dist/internal/sharing/apiService.js +2 -2
- package/dist/internal/sharing/apiService.js.map +1 -1
- package/dist/internal/sharing/cryptoService.d.ts +1 -1
- package/dist/internal/sharing/cryptoService.js +2 -2
- package/dist/internal/sharing/cryptoService.js.map +1 -1
- package/dist/internal/sharing/sharingManagement.d.ts +2 -2
- package/dist/internal/sharing/sharingManagement.js +29 -2
- package/dist/internal/sharing/sharingManagement.js.map +1 -1
- package/dist/internal/sharing/sharingManagement.test.js +48 -0
- package/dist/internal/sharing/sharingManagement.test.js.map +1 -1
- package/dist/internal/sharingPublic/index.js +1 -1
- package/dist/internal/sharingPublic/index.js.map +1 -1
- package/dist/protonDriveClient.d.ts +11 -2
- package/dist/protonDriveClient.js +12 -0
- package/dist/protonDriveClient.js.map +1 -1
- package/dist/protonDrivePhotosClient.d.ts +10 -1
- package/dist/protonDrivePhotosClient.js +12 -0
- package/dist/protonDrivePhotosClient.js.map +1 -1
- package/package.json +1 -1
- package/src/interface/account.ts +2 -1
- package/src/internal/batchLoading.test.ts +31 -1
- package/src/internal/batchLoading.ts +4 -1
- package/src/internal/nodes/cryptoService.test.ts +52 -2
- package/src/internal/nodes/cryptoService.ts +16 -1
- package/src/internal/nodes/index.ts +1 -1
- package/src/internal/nodes/nodesManagement.test.ts +38 -2
- package/src/internal/nodes/nodesManagement.ts +15 -3
- package/src/internal/photos/index.ts +1 -1
- package/src/internal/photos/nodes.test.ts +63 -1
- package/src/internal/photos/nodes.ts +16 -1
- package/src/internal/sharing/apiService.ts +2 -1
- package/src/internal/sharing/cryptoService.ts +2 -1
- package/src/internal/sharing/sharingManagement.test.ts +73 -0
- package/src/internal/sharing/sharingManagement.ts +47 -3
- package/src/internal/sharingPublic/index.ts +7 -1
- package/src/protonDriveClient.ts +19 -1
- package/src/protonDrivePhotosClient.ts +17 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ProtonDriveError } from '../errors';
|
|
1
|
+
import { AbortError, ProtonDriveError } from '../errors';
|
|
2
2
|
import { BatchLoading } from './batchLoading';
|
|
3
3
|
|
|
4
4
|
describe('BatchLoading', () => {
|
|
@@ -124,6 +124,36 @@ describe('BatchLoading', () => {
|
|
|
124
124
|
expect((thrown as ProtonDriveError).cause).toEqual([expect.objectContaining({ message: 'iterator failed' })]);
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
it('should rethrow AbortError immediately without accumulating', async () => {
|
|
128
|
+
const abortError = new AbortError();
|
|
129
|
+
const result: string[] = [];
|
|
130
|
+
const iterateItems = jest.fn(async function* (items: string[]) {
|
|
131
|
+
if (items.includes('a')) {
|
|
132
|
+
throw abortError;
|
|
133
|
+
}
|
|
134
|
+
for (const item of items) {
|
|
135
|
+
yield `loaded:${item}`;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
batchLoading = new BatchLoading<string, string>({ iterateItems, batchSize: 2 });
|
|
140
|
+
|
|
141
|
+
let thrown: unknown;
|
|
142
|
+
try {
|
|
143
|
+
for (const item of ['a', 'b', 'c', 'd']) {
|
|
144
|
+
for await (const loadedItem of batchLoading.load(item)) {
|
|
145
|
+
result.push(loadedItem);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
thrown = e;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
expect(result).toEqual([]);
|
|
153
|
+
expect(thrown).toBe(abortError);
|
|
154
|
+
expect(iterateItems).toHaveBeenCalledTimes(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
127
157
|
it('should throw ProtonDriveError with causes when multiple batches fail', async () => {
|
|
128
158
|
const loadItems = jest.fn((items: string[]) => {
|
|
129
159
|
if (items.includes('a') || items.includes('e')) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { c } from 'ttag';
|
|
2
2
|
|
|
3
|
-
import { ProtonDriveError } from '../errors';
|
|
3
|
+
import { AbortError, ProtonDriveError } from '../errors';
|
|
4
4
|
|
|
5
5
|
const DEFAULT_BATCH_LOADING = 10;
|
|
6
6
|
|
|
@@ -84,6 +84,9 @@ export class BatchLoading<ID, ITEM> {
|
|
|
84
84
|
try {
|
|
85
85
|
yield* this.iterateItems(items);
|
|
86
86
|
} catch (error) {
|
|
87
|
+
if (error instanceof AbortError) {
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
87
90
|
this.errors.push(error);
|
|
88
91
|
}
|
|
89
92
|
}
|
|
@@ -86,6 +86,10 @@ describe('nodesCryptoService', () => {
|
|
|
86
86
|
};
|
|
87
87
|
// @ts-expect-error No need to implement all methods for mocking
|
|
88
88
|
sharesService = {
|
|
89
|
+
getRootIDs: jest.fn(async () => ({
|
|
90
|
+
volumeId: 'volumeId',
|
|
91
|
+
rootNodeId: 'rootNodeId',
|
|
92
|
+
})),
|
|
89
93
|
getMyFilesShareMemberEmailKey: jest.fn(async () => ({
|
|
90
94
|
email: 'email',
|
|
91
95
|
addressKey: 'key' as unknown as PrivateKey,
|
|
@@ -94,7 +98,7 @@ describe('nodesCryptoService', () => {
|
|
|
94
98
|
};
|
|
95
99
|
|
|
96
100
|
const nodesCryptoReporter = new NodesCryptoReporter(telemetry, sharesService);
|
|
97
|
-
cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, nodesCryptoReporter);
|
|
101
|
+
cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService, nodesCryptoReporter);
|
|
98
102
|
});
|
|
99
103
|
|
|
100
104
|
const parentKey = 'parentKey' as unknown as PrivateKey;
|
|
@@ -579,6 +583,7 @@ describe('nodesCryptoService', () => {
|
|
|
579
583
|
const encryptedNode = {
|
|
580
584
|
uid: 'volumeId~nodeId',
|
|
581
585
|
parentUid: 'volumeId~parentId',
|
|
586
|
+
creationTime: new Date('2026-01-01'),
|
|
582
587
|
encryptedCrypto: {
|
|
583
588
|
signatureEmail: 'signatureEmail',
|
|
584
589
|
nameSignatureEmail: 'nameSignatureEmail',
|
|
@@ -786,7 +791,13 @@ describe('nodesCryptoService', () => {
|
|
|
786
791
|
}) as any,
|
|
787
792
|
);
|
|
788
793
|
|
|
789
|
-
const result = await cryptoService.decryptNode(
|
|
794
|
+
const result = await cryptoService.decryptNode(
|
|
795
|
+
{
|
|
796
|
+
...encryptedNode,
|
|
797
|
+
creationTime: new Date('2026-01-01'),
|
|
798
|
+
},
|
|
799
|
+
parentKey,
|
|
800
|
+
);
|
|
790
801
|
verifyResult(result, {
|
|
791
802
|
keyAuthor: {
|
|
792
803
|
ok: false,
|
|
@@ -796,12 +807,50 @@ describe('nodesCryptoService', () => {
|
|
|
796
807
|
},
|
|
797
808
|
},
|
|
798
809
|
});
|
|
810
|
+
expect(account.getOwnAddresses).not.toHaveBeenCalled();
|
|
799
811
|
verifyLogEventVerificationError({
|
|
800
812
|
field: 'nodeContentKey',
|
|
801
813
|
error: 'verification error',
|
|
802
814
|
});
|
|
803
815
|
});
|
|
804
816
|
|
|
817
|
+
it('on content key packet with skipped fallback verification for non-own volume', async () => {
|
|
818
|
+
driveCrypto.decryptAndVerifySessionKey = jest.fn(
|
|
819
|
+
async () =>
|
|
820
|
+
Promise.resolve({
|
|
821
|
+
sessionKey: 'contentKeyPacketSessionKey',
|
|
822
|
+
verified: VERIFICATION_STATUS.SIGNED_AND_INVALID,
|
|
823
|
+
verificationErrors: [new Error('verification error')],
|
|
824
|
+
}) as any,
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
const result = await cryptoService.decryptNode(
|
|
828
|
+
{
|
|
829
|
+
...encryptedNode,
|
|
830
|
+
uid: 'otherVolumeId~nodeId',
|
|
831
|
+
creationTime: new Date('2022-01-01'),
|
|
832
|
+
},
|
|
833
|
+
parentKey,
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
verifyResult(result, {
|
|
837
|
+
keyAuthor: {
|
|
838
|
+
ok: false,
|
|
839
|
+
error: {
|
|
840
|
+
claimedAuthor: 'signatureEmail',
|
|
841
|
+
error: 'Signature verification for content key failed: verification error',
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
expect(account.getOwnAddresses).not.toHaveBeenCalled();
|
|
846
|
+
verifyLogEventVerificationError({
|
|
847
|
+
field: 'nodeContentKey',
|
|
848
|
+
error: 'verification error',
|
|
849
|
+
uid: 'otherVolumeId~nodeId',
|
|
850
|
+
fromBefore2024: true,
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
|
|
805
854
|
it('on content key packet with successful fallback verification', async () => {
|
|
806
855
|
driveCrypto.decryptAndVerifySessionKey = jest
|
|
807
856
|
.fn()
|
|
@@ -829,6 +878,7 @@ describe('nodesCryptoService', () => {
|
|
|
829
878
|
parentKey,
|
|
830
879
|
);
|
|
831
880
|
verifyResult(result);
|
|
881
|
+
expect(account.getOwnAddresses).toHaveBeenCalled();
|
|
832
882
|
expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledTimes(2);
|
|
833
883
|
expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledWith(
|
|
834
884
|
'base64ContentKeyPacket',
|
|
@@ -33,7 +33,9 @@ import {
|
|
|
33
33
|
DecryptedUnparsedRevision,
|
|
34
34
|
NodeSigningKeys,
|
|
35
35
|
EncryptedNodeFileCrypto,
|
|
36
|
+
SharesService,
|
|
36
37
|
} from './interface';
|
|
38
|
+
import { splitNodeUid } from '../uids';
|
|
37
39
|
|
|
38
40
|
export interface NodesCryptoReporter {
|
|
39
41
|
handleClaimedAuthor(
|
|
@@ -76,6 +78,7 @@ export class NodesCryptoService {
|
|
|
76
78
|
telemetry: ProtonDriveTelemetry,
|
|
77
79
|
protected driveCrypto: DriveCrypto,
|
|
78
80
|
private account: ProtonDriveAccount,
|
|
81
|
+
private sharesService: Pick<SharesService, 'getRootIDs'>,
|
|
79
82
|
private reporter: NodesCryptoReporter,
|
|
80
83
|
) {
|
|
81
84
|
this.logger = telemetry.getLogger('nodes-crypto');
|
|
@@ -538,6 +541,15 @@ export class NodesCryptoService {
|
|
|
538
541
|
return result;
|
|
539
542
|
}
|
|
540
543
|
|
|
544
|
+
const { volumeId: ownVolumeId } = await this.sharesService.getRootIDs();
|
|
545
|
+
const { volumeId: nodesVolumeId } = splitNodeUid(node.uid);
|
|
546
|
+
|
|
547
|
+
// If the node is not in the own volume, skip the fallback verification,
|
|
548
|
+
// because it is not possible to load all owners' address keys.
|
|
549
|
+
if (ownVolumeId !== nodesVolumeId) {
|
|
550
|
+
return result;
|
|
551
|
+
}
|
|
552
|
+
|
|
541
553
|
const allAddresses = await this.account.getOwnAddresses();
|
|
542
554
|
const allKeys = allAddresses.flatMap((address) => address.keys.map(({ key }) => key));
|
|
543
555
|
|
|
@@ -745,7 +757,10 @@ export class NodesCryptoService {
|
|
|
745
757
|
};
|
|
746
758
|
}
|
|
747
759
|
|
|
748
|
-
async generateNameHashes(
|
|
760
|
+
async generateNameHashes(
|
|
761
|
+
parentHashKey: Uint8Array<ArrayBuffer>,
|
|
762
|
+
names: string[],
|
|
763
|
+
): Promise<{ name: string; hash: string }[]> {
|
|
749
764
|
return Promise.all(
|
|
750
765
|
names.map(async (name) => ({
|
|
751
766
|
name,
|
|
@@ -43,7 +43,7 @@ export function initNodesModule(
|
|
|
43
43
|
const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache);
|
|
44
44
|
const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache);
|
|
45
45
|
const cryptoReporter = new NodesCryptoReporter(telemetry, sharesService);
|
|
46
|
-
const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter);
|
|
46
|
+
const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService, cryptoReporter);
|
|
47
47
|
const nodesAccess = new NodesAccess(telemetry, api, cache, cryptoCache, cryptoService, sharesService);
|
|
48
48
|
const nodesEventHandler = new NodesEventsHandler(telemetry.getLogger('nodes-events'), cache);
|
|
49
49
|
const nodesManagement = new NodesManagement(api, cryptoCache, cryptoService, nodesAccess);
|
|
@@ -4,9 +4,9 @@ import { NodesCryptoService } from './cryptoService';
|
|
|
4
4
|
import { NodesAccess } from './nodesAccess';
|
|
5
5
|
import { DecryptedNode } from './interface';
|
|
6
6
|
import { NodesManagement } from './nodesManagement';
|
|
7
|
-
import { NodeResult } from '../../interface';
|
|
7
|
+
import { NodeResult, NodeResultWithError } from '../../interface';
|
|
8
8
|
import { NodeOutOfSyncError } from './errors';
|
|
9
|
-
import { ValidationError } from '../../errors';
|
|
9
|
+
import { NodeWithSameNameExistsValidationError, ValidationError } from '../../errors';
|
|
10
10
|
|
|
11
11
|
describe('NodesManagement', () => {
|
|
12
12
|
let apiService: NodeAPIService;
|
|
@@ -263,6 +263,42 @@ describe('NodesManagement', () => {
|
|
|
263
263
|
);
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
+
it('moveNodes yields NodeWithSameNameExistsValidationError in case of duplicate node name', async () => {
|
|
267
|
+
const encryptedCrypto = {
|
|
268
|
+
encryptedName: 'movedArmoredNodeName',
|
|
269
|
+
hash: 'movedHash',
|
|
270
|
+
armoredNodePassphrase: 'movedArmoredNodePassphrase',
|
|
271
|
+
armoredNodePassphraseSignature: 'movedArmoredNodePassphraseSignature',
|
|
272
|
+
signatureEmail: 'movedSignatureEmail',
|
|
273
|
+
nameSignatureEmail: 'movedNameSignatureEmail',
|
|
274
|
+
};
|
|
275
|
+
cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto);
|
|
276
|
+
const error = new NodeWithSameNameExistsValidationError('Node with same name exists', 2500, 'existingNodeUid');
|
|
277
|
+
apiService.moveNode = jest.fn().mockRejectedValue(error);
|
|
278
|
+
|
|
279
|
+
const results: NodeResultWithError[] = [];
|
|
280
|
+
for await (const result of management.moveNodes(['nodeUid'], 'newParentNodeUid')) {
|
|
281
|
+
results.push(result);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
expect(results).toHaveLength(1);
|
|
285
|
+
expect(results[0]).toEqual({ uid: 'nodeUid', ok: false, error });
|
|
286
|
+
expect(results[0].ok === false && results[0].error).toBeInstanceOf(NodeWithSameNameExistsValidationError);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('moveNodes yields NodeResultWithError with Error on failure', async () => {
|
|
290
|
+
const error = new Error('move failed');
|
|
291
|
+
cryptoService.encryptNodeWithNewParent = jest.fn().mockRejectedValue(error);
|
|
292
|
+
|
|
293
|
+
const results: NodeResultWithError[] = [];
|
|
294
|
+
for await (const result of management.moveNodes(['nodeUid'], 'newParentNodeUid')) {
|
|
295
|
+
results.push(result);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
expect(results).toHaveLength(1);
|
|
299
|
+
expect(results[0]).toEqual({ uid: 'nodeUid', ok: false, error });
|
|
300
|
+
});
|
|
301
|
+
|
|
266
302
|
it('copyNode manages copy and updates cache', async () => {
|
|
267
303
|
const encryptedCrypto = {
|
|
268
304
|
encryptedName: 'copiedArmoredNodeName',
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { c } from 'ttag';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
MemberRole,
|
|
5
|
+
NodeType,
|
|
6
|
+
NodeResult,
|
|
7
|
+
NodeResultWithNewUid,
|
|
8
|
+
resultOk,
|
|
9
|
+
InvalidNameError,
|
|
10
|
+
NodeResultWithError,
|
|
11
|
+
} from '../../interface';
|
|
4
12
|
import { AbortError, ValidationError } from '../../errors';
|
|
5
13
|
import { createErrorFromUnknown, getErrorMessage } from '../errors';
|
|
6
14
|
import { splitNodeUid } from '../uids';
|
|
@@ -107,7 +115,11 @@ export abstract class NodesManagementBase<
|
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
// Improvement requested: move nodes in parallel
|
|
110
|
-
async *moveNodes(
|
|
118
|
+
async *moveNodes(
|
|
119
|
+
nodeUids: string[],
|
|
120
|
+
newParentNodeUid: string,
|
|
121
|
+
signal?: AbortSignal,
|
|
122
|
+
): AsyncGenerator<NodeResultWithError> {
|
|
111
123
|
for (const nodeUid of nodeUids) {
|
|
112
124
|
if (signal?.aborted) {
|
|
113
125
|
throw new AbortError(c('Error').t`Move operation aborted`);
|
|
@@ -122,7 +134,7 @@ export abstract class NodesManagementBase<
|
|
|
122
134
|
yield {
|
|
123
135
|
uid: nodeUid,
|
|
124
136
|
ok: false,
|
|
125
|
-
error: getErrorMessage(error),
|
|
137
|
+
error: error instanceof Error ? error : new Error(getErrorMessage(error), { cause: error }),
|
|
126
138
|
};
|
|
127
139
|
}
|
|
128
140
|
}
|
|
@@ -123,7 +123,7 @@ export function initPhotosNodesModule(
|
|
|
123
123
|
const cache = new PhotosNodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache);
|
|
124
124
|
const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache);
|
|
125
125
|
const cryptoReporter = new NodesCryptoReporter(telemetry, sharesService);
|
|
126
|
-
const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter);
|
|
126
|
+
const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService, cryptoReporter);
|
|
127
127
|
const nodesAccess = new PhotosNodesAccess(telemetry, api, cache, cryptoCache, cryptoService, sharesService);
|
|
128
128
|
const nodesEventHandler = new NodesEventsHandler(telemetry.getLogger('nodes-events'), cache);
|
|
129
129
|
const nodesManagement = new PhotosNodesManagement(api, cryptoCache, cryptoService, nodesAccess);
|
|
@@ -206,6 +206,61 @@ describe('PhotosNodesCache', () => {
|
|
|
206
206
|
});
|
|
207
207
|
|
|
208
208
|
describe('PhotosNodesAccess', () => {
|
|
209
|
+
describe('getParentKeys', () => {
|
|
210
|
+
let access: PhotosNodesAccess;
|
|
211
|
+
let getNodeKeysMock: jest.Mock;
|
|
212
|
+
let getSharePrivateKeyMock: jest.Mock;
|
|
213
|
+
|
|
214
|
+
beforeEach(() => {
|
|
215
|
+
getNodeKeysMock = jest.fn().mockResolvedValue({ key: 'key', hashKey: 'hashKey' });
|
|
216
|
+
getSharePrivateKeyMock = jest.fn().mockResolvedValue('shareKey');
|
|
217
|
+
access = new PhotosNodesAccess(
|
|
218
|
+
getMockTelemetry(),
|
|
219
|
+
// @ts-expect-error No need to implement for this test
|
|
220
|
+
{},
|
|
221
|
+
{},
|
|
222
|
+
{ getNodeKeys: jest.fn().mockRejectedValue(new Error()) },
|
|
223
|
+
{},
|
|
224
|
+
{ getSharePrivateKey: getSharePrivateKeyMock },
|
|
225
|
+
);
|
|
226
|
+
jest.spyOn(access, 'getNodeKeys').mockImplementation(getNodeKeysMock);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should use parentUid path when set, ignoring shareId', async () => {
|
|
230
|
+
await access.getParentKeys({
|
|
231
|
+
uid: 'v~node',
|
|
232
|
+
parentUid: 'v~parent',
|
|
233
|
+
shareId: 'publicLinkShareId',
|
|
234
|
+
photo: undefined,
|
|
235
|
+
});
|
|
236
|
+
expect(getNodeKeysMock).toHaveBeenCalledWith('v~parent');
|
|
237
|
+
expect(getSharePrivateKeyMock).not.toHaveBeenCalled();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should use album key when no parentUid but has albums, even when shareId is set', async () => {
|
|
241
|
+
await access.getParentKeys({
|
|
242
|
+
uid: 'v~node',
|
|
243
|
+
parentUid: undefined,
|
|
244
|
+
shareId: 'publicLinkShareId',
|
|
245
|
+
// @ts-expect-error No need to implement for this test
|
|
246
|
+
photo: { albums: [{ nodeUid: 'v~album' }] },
|
|
247
|
+
});
|
|
248
|
+
expect(getNodeKeysMock).toHaveBeenCalledWith('v~album');
|
|
249
|
+
expect(getSharePrivateKeyMock).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should fall back to shareId when no parentUid and no albums', async () => {
|
|
253
|
+
await access.getParentKeys({
|
|
254
|
+
uid: 'v~node',
|
|
255
|
+
parentUid: undefined,
|
|
256
|
+
shareId: 'rootShareId',
|
|
257
|
+
// @ts-expect-error No need to implement for this test
|
|
258
|
+
photo: { albums: [] },
|
|
259
|
+
});
|
|
260
|
+
expect(getSharePrivateKeyMock).toHaveBeenCalledWith('rootShareId');
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
209
264
|
describe('parseNode', () => {
|
|
210
265
|
it('should keep photo type and add photo object', async () => {
|
|
211
266
|
const telemetry = getMockTelemetry();
|
|
@@ -222,7 +277,14 @@ describe('PhotosNodesAccess', () => {
|
|
|
222
277
|
const sharesService: SharesService = {};
|
|
223
278
|
|
|
224
279
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
225
|
-
const nodesAccess = new PhotosNodesAccess(
|
|
280
|
+
const nodesAccess = new PhotosNodesAccess(
|
|
281
|
+
telemetry,
|
|
282
|
+
apiService,
|
|
283
|
+
cacheService,
|
|
284
|
+
cryptoCache,
|
|
285
|
+
cryptoService,
|
|
286
|
+
sharesService,
|
|
287
|
+
);
|
|
226
288
|
|
|
227
289
|
const unparsedNode = {
|
|
228
290
|
uid: 'volumeId~linkId',
|
|
@@ -151,7 +151,18 @@ export class PhotosNodesAccess extends NodesAccessBase<EncryptedPhotoNode, Decry
|
|
|
151
151
|
async getParentKeys(
|
|
152
152
|
node: Pick<EncryptedPhotoNode, 'uid' | 'parentUid' | 'shareId' | 'photo'>,
|
|
153
153
|
): Promise<Pick<DecryptedNodeKeys, 'key' | 'hashKey'>> {
|
|
154
|
-
|
|
154
|
+
// In regular case, the parent should be used first as it is guaranteed that
|
|
155
|
+
// the root node without parent will have a share with direct membership for
|
|
156
|
+
// the user that can be used to decrypt the node.
|
|
157
|
+
// For photos, the parent might be missing but then an album (or more) plays
|
|
158
|
+
// the role of the parent. It must be used first before fallbacking to share
|
|
159
|
+
// because the node might be shared but user is not directly invited and thus
|
|
160
|
+
// cannot decrypt via the share (user's address cannot decrypt).
|
|
161
|
+
// Using parent path first should stay as if present, it will be fastest way
|
|
162
|
+
// to decrypt for the owner - all photos in the timeline can use already
|
|
163
|
+
// cached key without the need to load albums as well.
|
|
164
|
+
|
|
165
|
+
if (node.parentUid) {
|
|
155
166
|
return super.getParentKeys(node);
|
|
156
167
|
}
|
|
157
168
|
|
|
@@ -176,6 +187,10 @@ export class PhotosNodesAccess extends NodesAccessBase<EncryptedPhotoNode, Decry
|
|
|
176
187
|
return this.getNodeKeys(albumNodeUid);
|
|
177
188
|
}
|
|
178
189
|
|
|
190
|
+
if (node.shareId) {
|
|
191
|
+
return super.getParentKeys(node);
|
|
192
|
+
}
|
|
193
|
+
|
|
179
194
|
// This is bug that should not happen.
|
|
180
195
|
// API cannot provide node without parent or share or album.
|
|
181
196
|
throw new Error(`Node has neither parent node nor share nor album: ${node.uid}`);
|
|
@@ -433,6 +433,7 @@ export class SharingAPIService {
|
|
|
433
433
|
shareId: string,
|
|
434
434
|
invitation: EncryptedInvitationRequest,
|
|
435
435
|
emailDetails: { message?: string; nodeName?: string } = {},
|
|
436
|
+
externalInvitationId: string | null = null,
|
|
436
437
|
): Promise<EncryptedInvitation> {
|
|
437
438
|
const response = await this.apiService.post<PostInviteProtonUserRequest, PostInviteProtonUserResponse>(
|
|
438
439
|
`drive/v2/shares/${shareId}/invitations`,
|
|
@@ -443,7 +444,7 @@ export class SharingAPIService {
|
|
|
443
444
|
Permissions: memberRoleToPermission(invitation.role),
|
|
444
445
|
KeyPacket: invitation.base64KeyPacket,
|
|
445
446
|
KeyPacketSignature: invitation.base64KeyPacketSignature,
|
|
446
|
-
ExternalInvitationID:
|
|
447
|
+
ExternalInvitationID: externalInvitationId,
|
|
447
448
|
},
|
|
448
449
|
EmailDetails: {
|
|
449
450
|
Message: emailDetails.message,
|
|
@@ -182,11 +182,12 @@ export class SharingCryptoService {
|
|
|
182
182
|
shareSessionKey: SessionKey,
|
|
183
183
|
inviterKey: PrivateKey,
|
|
184
184
|
inviteeEmail: string,
|
|
185
|
+
forceRefreshKeys?: boolean,
|
|
185
186
|
): Promise<{
|
|
186
187
|
base64KeyPacket: string;
|
|
187
188
|
base64KeyPacketSignature: string;
|
|
188
189
|
}> {
|
|
189
|
-
const inviteePublicKeys = await this.account.getPublicKeys(inviteeEmail);
|
|
190
|
+
const inviteePublicKeys = await this.account.getPublicKeys(inviteeEmail, forceRefreshKeys);
|
|
190
191
|
const result = await this.driveCrypto.encryptInvitation(shareSessionKey, inviteePublicKeys[0], inviterKey);
|
|
191
192
|
return result;
|
|
192
193
|
}
|
|
@@ -1138,4 +1138,77 @@ describe('SharingManagement', () => {
|
|
|
1138
1138
|
expect(apiService.resendExternalInvitationEmail).not.toHaveBeenCalled();
|
|
1139
1139
|
});
|
|
1140
1140
|
});
|
|
1141
|
+
|
|
1142
|
+
describe('convertNonProtonInvitation', () => {
|
|
1143
|
+
const nodeUid = 'volumeId~nodeId';
|
|
1144
|
+
const externalInvitationId = 'inv123';
|
|
1145
|
+
const externalInvitationUid = `${DEFAULT_SHARE_ID}~${externalInvitationId}`;
|
|
1146
|
+
const externalInvitation: NonProtonInvitation = {
|
|
1147
|
+
uid: externalInvitationUid,
|
|
1148
|
+
inviteeEmail: 'external@example.com',
|
|
1149
|
+
addedByEmail: resultOk('inviter@example.com'),
|
|
1150
|
+
role: MemberRole.Viewer,
|
|
1151
|
+
invitationTime: new Date(),
|
|
1152
|
+
state: NonProtonInvitationState.Pending,
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
beforeEach(() => {
|
|
1156
|
+
nodesService.getNode = jest.fn().mockResolvedValue({
|
|
1157
|
+
nodeUid,
|
|
1158
|
+
shareId: DEFAULT_SHARE_ID,
|
|
1159
|
+
directRole: MemberRole.Admin,
|
|
1160
|
+
name: { ok: true, value: 'name' },
|
|
1161
|
+
});
|
|
1162
|
+
apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([externalInvitation]);
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
it('should throw if caller is not admin', async () => {
|
|
1166
|
+
nodesService.getNode = jest.fn().mockResolvedValue({
|
|
1167
|
+
nodeUid,
|
|
1168
|
+
shareId: DEFAULT_SHARE_ID,
|
|
1169
|
+
directRole: MemberRole.Viewer,
|
|
1170
|
+
name: { ok: true, value: 'name' },
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
await expect(
|
|
1174
|
+
sharingManagement.convertNonProtonInvitation(nodeUid, externalInvitationUid),
|
|
1175
|
+
).rejects.toThrow(ValidationError);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it('should throw if no sharing info found', async () => {
|
|
1179
|
+
nodesService.getNode = jest.fn().mockResolvedValue({
|
|
1180
|
+
nodeUid,
|
|
1181
|
+
shareId: undefined,
|
|
1182
|
+
directRole: MemberRole.Admin,
|
|
1183
|
+
name: { ok: true, value: 'name' },
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
await expect(
|
|
1187
|
+
sharingManagement.convertNonProtonInvitation(nodeUid, externalInvitationUid),
|
|
1188
|
+
).rejects.toThrow(ValidationError);
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
it('should throw if external invitation ID is not found', async () => {
|
|
1192
|
+
await expect(
|
|
1193
|
+
sharingManagement.convertNonProtonInvitation(nodeUid, 'unknownShareId~unknownInvId'),
|
|
1194
|
+
).rejects.toThrow(ValidationError);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
it('should invite proton user with force-refreshed keys and the external invitation ID', async () => {
|
|
1198
|
+
await sharingManagement.convertNonProtonInvitation(nodeUid, externalInvitationUid);
|
|
1199
|
+
|
|
1200
|
+
expect(cryptoService.encryptInvitation).toHaveBeenCalledWith(
|
|
1201
|
+
expect.anything(),
|
|
1202
|
+
expect.anything(),
|
|
1203
|
+
externalInvitation.inviteeEmail,
|
|
1204
|
+
true,
|
|
1205
|
+
);
|
|
1206
|
+
expect(apiService.inviteProtonUser).toHaveBeenCalledWith(
|
|
1207
|
+
DEFAULT_SHARE_ID,
|
|
1208
|
+
expect.objectContaining({ inviteeEmail: externalInvitation.inviteeEmail, role: externalInvitation.role }),
|
|
1209
|
+
{},
|
|
1210
|
+
externalInvitationId,
|
|
1211
|
+
);
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1141
1214
|
});
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
SharePublicLinkSettingsObject,
|
|
17
17
|
} from '../../interface';
|
|
18
18
|
import { ErrorCode } from '../apiService';
|
|
19
|
-
import { splitNodeUid } from '../uids';
|
|
19
|
+
import { splitNodeUid, splitInvitationUid } from '../uids';
|
|
20
20
|
import { getErrorMessage } from '../errors';
|
|
21
21
|
import { SharingAPIService } from './apiService';
|
|
22
22
|
import { PUBLIC_LINK_GENERATED_PASSWORD_LENGTH, SharingCryptoService } from './cryptoService';
|
|
@@ -596,8 +596,52 @@ export class SharingManagement {
|
|
|
596
596
|
await this.apiService.deleteExternalInvitation(invitationUid);
|
|
597
597
|
}
|
|
598
598
|
|
|
599
|
-
|
|
600
|
-
|
|
599
|
+
async convertNonProtonInvitation(nodeUid: string, nonProtonInvitationUid: string): Promise<ProtonInvitation> {
|
|
600
|
+
const { invitationId: externalInvitationId } = splitInvitationUid(nonProtonInvitationUid);
|
|
601
|
+
|
|
602
|
+
const node = await this.nodesService.getNode(nodeUid);
|
|
603
|
+
if (node.directRole !== MemberRole.Admin) {
|
|
604
|
+
throw new ValidationError(c('Error').t`Only admins can convert non-Proton invitations`);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const [currentSharing, inviter] = await Promise.all([
|
|
608
|
+
this.getInternalSharingInfo(nodeUid),
|
|
609
|
+
this.nodesService.getRootNodeEmailKey(nodeUid),
|
|
610
|
+
]);
|
|
611
|
+
if (!currentSharing) {
|
|
612
|
+
throw new ValidationError(c('Error').t`The node is not shared anymore`);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const externalInvitation = currentSharing.nonProtonInvitations.find(
|
|
616
|
+
(invitation) => invitation.uid === nonProtonInvitationUid,
|
|
617
|
+
);
|
|
618
|
+
if (!externalInvitation) {
|
|
619
|
+
throw new ValidationError(c('Error').t`Invitation not found`);
|
|
620
|
+
}
|
|
621
|
+
this.logger.info(
|
|
622
|
+
`Converting non-Proton invitation for ${externalInvitation.inviteeEmail} to internal for node ${nodeUid}`,
|
|
623
|
+
);
|
|
624
|
+
const invitationCrypto = await this.cryptoService.encryptInvitation(
|
|
625
|
+
currentSharing.share.passphraseSessionKey,
|
|
626
|
+
inviter.addressKey,
|
|
627
|
+
externalInvitation.inviteeEmail,
|
|
628
|
+
true, // Force refresh keys: the invitee just created a Proton account, so we have "absent" keys in cache
|
|
629
|
+
);
|
|
630
|
+
const encryptedInvitation = await this.apiService.inviteProtonUser(
|
|
631
|
+
currentSharing.share.shareId,
|
|
632
|
+
{
|
|
633
|
+
addedByEmail: inviter.email,
|
|
634
|
+
inviteeEmail: externalInvitation.inviteeEmail,
|
|
635
|
+
role: externalInvitation.role,
|
|
636
|
+
...invitationCrypto,
|
|
637
|
+
},
|
|
638
|
+
{},
|
|
639
|
+
externalInvitationId,
|
|
640
|
+
);
|
|
641
|
+
return {
|
|
642
|
+
...encryptedInvitation,
|
|
643
|
+
addedByEmail: resultOk(encryptedInvitation.addedByEmail),
|
|
644
|
+
};
|
|
601
645
|
}
|
|
602
646
|
|
|
603
647
|
private async removeMember(memberUid: string): Promise<void> {
|
|
@@ -100,7 +100,13 @@ export function initSharingPublicNodesModule(
|
|
|
100
100
|
const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache);
|
|
101
101
|
const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache);
|
|
102
102
|
const cryptoReporter = new SharingPublicCryptoReporter(telemetry);
|
|
103
|
-
const cryptoService = new SharingPublicNodesCryptoService(
|
|
103
|
+
const cryptoService = new SharingPublicNodesCryptoService(
|
|
104
|
+
telemetry,
|
|
105
|
+
driveCrypto,
|
|
106
|
+
account,
|
|
107
|
+
sharesService,
|
|
108
|
+
cryptoReporter,
|
|
109
|
+
);
|
|
104
110
|
const nodesAccess = new SharingPublicNodesAccess(
|
|
105
111
|
telemetry,
|
|
106
112
|
api,
|
package/src/protonDriveClient.ts
CHANGED
|
@@ -8,11 +8,13 @@ import {
|
|
|
8
8
|
MaybeNode,
|
|
9
9
|
MaybeMissingNode,
|
|
10
10
|
NodeResult,
|
|
11
|
+
NodeResultWithError,
|
|
11
12
|
NodeResultWithNewUid,
|
|
12
13
|
Revision,
|
|
13
14
|
RevisionOrUid,
|
|
14
15
|
ShareNodeSettings,
|
|
15
16
|
UnshareNodeSettings,
|
|
17
|
+
ProtonInvitation,
|
|
16
18
|
ProtonInvitationOrUid,
|
|
17
19
|
NonProtonInvitationOrUid,
|
|
18
20
|
ProtonInvitationWithNode,
|
|
@@ -420,7 +422,7 @@ export class ProtonDriveClient {
|
|
|
420
422
|
nodeUids: NodeOrUid[],
|
|
421
423
|
newParentNodeUid: NodeOrUid,
|
|
422
424
|
signal?: AbortSignal,
|
|
423
|
-
): AsyncGenerator<
|
|
425
|
+
): AsyncGenerator<NodeResultWithError> {
|
|
424
426
|
this.logger.info(`Moving ${nodeUids.length} nodes to ${getUid(newParentNodeUid)}`);
|
|
425
427
|
yield* this.nodes.management.moveNodes(getUids(nodeUids), getUid(newParentNodeUid), signal);
|
|
426
428
|
}
|
|
@@ -749,6 +751,22 @@ export class ProtonDriveClient {
|
|
|
749
751
|
return this.sharing.management.unshareNode(getUid(nodeUid), settings);
|
|
750
752
|
}
|
|
751
753
|
|
|
754
|
+
/**
|
|
755
|
+
* Convert a non-Proton invitation to an internal invitation.
|
|
756
|
+
* This is called automatically in the background when the SDK receives
|
|
757
|
+
* a metadata update event, but can also be triggered manually.
|
|
758
|
+
*
|
|
759
|
+
* @param nodeUid - Node entity or its UID string.
|
|
760
|
+
* @param invitationOrUid - Non-Proton invitation entity or its UID string.
|
|
761
|
+
*/
|
|
762
|
+
async convertNonProtonInvitation(
|
|
763
|
+
nodeUid: NodeOrUid,
|
|
764
|
+
invitationOrUid: NonProtonInvitationOrUid,
|
|
765
|
+
): Promise<ProtonInvitation> {
|
|
766
|
+
this.logger.info(`Converting non-Proton invitation ${getUid(invitationOrUid)} for node ${getUid(nodeUid)}`);
|
|
767
|
+
return this.sharing.management.convertNonProtonInvitation(getUid(nodeUid), getUid(invitationOrUid));
|
|
768
|
+
}
|
|
769
|
+
|
|
752
770
|
/**
|
|
753
771
|
* Resend the invitation email to shared node.
|
|
754
772
|
*
|