@protontech/drive-sdk 0.14.3 → 0.14.5
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/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/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 +45 -4
- package/dist/internal/sharing/sharingManagement.js.map +1 -1
- package/dist/internal/sharing/sharingManagement.test.js +74 -4
- 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/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/sharing/apiService.ts +2 -1
- package/src/internal/sharing/cryptoService.ts +2 -1
- package/src/internal/sharing/sharingManagement.test.ts +104 -4
- package/src/internal/sharing/sharingManagement.ts +68 -5
- package/src/internal/sharingPublic/index.ts +7 -1
- package/src/protonDriveClient.ts +19 -1
- package/src/protonDrivePhotosClient.ts +17 -0
|
@@ -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);
|
|
@@ -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
|
}
|
|
@@ -106,6 +106,7 @@ describe('SharingManagement', () => {
|
|
|
106
106
|
creatorEmail: 'address@example.com',
|
|
107
107
|
passphraseSessionKey: 'sharePassphraseSessionKey',
|
|
108
108
|
}),
|
|
109
|
+
getRootIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }),
|
|
109
110
|
};
|
|
110
111
|
// @ts-expect-error No need to implement all methods for mocking
|
|
111
112
|
nodesService = {
|
|
@@ -146,7 +147,7 @@ describe('SharingManagement', () => {
|
|
|
146
147
|
const invitation = { uid: 'invitaiton', addedByEmail: 'email' };
|
|
147
148
|
apiService.getShareInvitations = jest.fn().mockResolvedValue([invitation]);
|
|
148
149
|
|
|
149
|
-
const sharingInfo = await sharingManagement.getSharingInfo('nodeUid');
|
|
150
|
+
const sharingInfo = await sharingManagement.getSharingInfo('volumeId~nodeUid');
|
|
150
151
|
|
|
151
152
|
expect(sharingInfo).toEqual({
|
|
152
153
|
protonInvitations: [invitation],
|
|
@@ -161,7 +162,7 @@ describe('SharingManagement', () => {
|
|
|
161
162
|
const externalInvitation = { uid: 'external-invitation', addedByEmail: 'email' };
|
|
162
163
|
apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([externalInvitation]);
|
|
163
164
|
|
|
164
|
-
const sharingInfo = await sharingManagement.getSharingInfo('nodeUid');
|
|
165
|
+
const sharingInfo = await sharingManagement.getSharingInfo('volumeId~nodeUid');
|
|
165
166
|
|
|
166
167
|
expect(sharingInfo).toEqual({
|
|
167
168
|
protonInvitations: [],
|
|
@@ -176,7 +177,7 @@ describe('SharingManagement', () => {
|
|
|
176
177
|
const member = { uid: 'member', addedByEmail: 'email' };
|
|
177
178
|
apiService.getShareMembers = jest.fn().mockResolvedValue([member]);
|
|
178
179
|
|
|
179
|
-
const sharingInfo = await sharingManagement.getSharingInfo('nodeUid');
|
|
180
|
+
const sharingInfo = await sharingManagement.getSharingInfo('volumeId~nodeUid');
|
|
180
181
|
|
|
181
182
|
expect(sharingInfo).toEqual({
|
|
182
183
|
protonInvitations: [],
|
|
@@ -193,7 +194,7 @@ describe('SharingManagement', () => {
|
|
|
193
194
|
};
|
|
194
195
|
apiService.getPublicLink = jest.fn().mockResolvedValue(publicLink);
|
|
195
196
|
|
|
196
|
-
const sharingInfo = await sharingManagement.getSharingInfo('nodeUid');
|
|
197
|
+
const sharingInfo = await sharingManagement.getSharingInfo('volumeId~nodeUid');
|
|
197
198
|
|
|
198
199
|
expect(sharingInfo).toEqual({
|
|
199
200
|
protonInvitations: [],
|
|
@@ -203,6 +204,19 @@ describe('SharingManagement', () => {
|
|
|
203
204
|
});
|
|
204
205
|
expect(cryptoService.decryptPublicLink).toHaveBeenCalledWith(publicLink);
|
|
205
206
|
});
|
|
207
|
+
|
|
208
|
+
it('should NOT return public link when volume ID does not match', async () => {
|
|
209
|
+
apiService.getPublicLink = jest.fn().mockResolvedValue(null);
|
|
210
|
+
const sharingInfo = await sharingManagement.getSharingInfo('zolumeId~nodeUid');
|
|
211
|
+
expect(sharingInfo).toEqual({
|
|
212
|
+
protonInvitations: [],
|
|
213
|
+
nonProtonInvitations: [],
|
|
214
|
+
members: [],
|
|
215
|
+
publicLink: undefined,
|
|
216
|
+
});
|
|
217
|
+
expect(apiService.getPublicLink).not.toHaveBeenCalled();
|
|
218
|
+
expect(cryptoService.decryptPublicLink).not.toHaveBeenCalled();
|
|
219
|
+
});
|
|
206
220
|
});
|
|
207
221
|
|
|
208
222
|
describe('shareNode with share creation', () => {
|
|
@@ -889,6 +903,19 @@ describe('SharingManagement', () => {
|
|
|
889
903
|
expect(apiService.createStandardShare).not.toHaveBeenCalled();
|
|
890
904
|
expect(apiService.createPublicLink).not.toHaveBeenCalled();
|
|
891
905
|
});
|
|
906
|
+
|
|
907
|
+
it('should not allow creating public link for volume not owned by user', async () => {
|
|
908
|
+
sharesService.getRootIDs = jest.fn().mockResolvedValue({ volumeId: 'differentVolumeId' });
|
|
909
|
+
await expect(
|
|
910
|
+
sharingManagement.shareNode(nodeUid, {
|
|
911
|
+
publicLink: {
|
|
912
|
+
role: MemberRole.Viewer,
|
|
913
|
+
},
|
|
914
|
+
}),
|
|
915
|
+
).rejects.toThrow('Cannot create public link for volume not owned by the user');
|
|
916
|
+
|
|
917
|
+
expect(apiService.createPublicLink).not.toHaveBeenCalled();
|
|
918
|
+
});
|
|
892
919
|
});
|
|
893
920
|
});
|
|
894
921
|
|
|
@@ -1111,4 +1138,77 @@ describe('SharingManagement', () => {
|
|
|
1111
1138
|
expect(apiService.resendExternalInvitationEmail).not.toHaveBeenCalled();
|
|
1112
1139
|
});
|
|
1113
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
|
+
});
|
|
1114
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';
|
|
@@ -77,11 +77,13 @@ export class SharingManagement {
|
|
|
77
77
|
return;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
const { volumeId } = splitNodeUid(nodeUid);
|
|
81
|
+
|
|
80
82
|
const [protonInvitations, nonProtonInvitations, members, publicLink, share] = await Promise.all([
|
|
81
83
|
Array.fromAsync(this.iterateShareInvitations(node.shareId)),
|
|
82
84
|
Array.fromAsync(this.iterateShareExternalInvitations(node.shareId)),
|
|
83
85
|
Array.fromAsync(this.iterateShareMembers(node.shareId)),
|
|
84
|
-
this.getPublicLink(node.shareId),
|
|
86
|
+
this.getPublicLink(node.shareId, volumeId),
|
|
85
87
|
this.sharesService.loadEncryptedShare(node.shareId),
|
|
86
88
|
]);
|
|
87
89
|
|
|
@@ -115,11 +117,18 @@ export class SharingManagement {
|
|
|
115
117
|
}
|
|
116
118
|
}
|
|
117
119
|
|
|
118
|
-
private async getPublicLink(shareId: string): Promise<PublicLinkWithCreatorEmail | undefined> {
|
|
120
|
+
private async getPublicLink(shareId: string, volumeId: string): Promise<PublicLinkWithCreatorEmail | undefined> {
|
|
121
|
+
const rootIds = await this.sharesService.getRootIDs();
|
|
122
|
+
// Public links are encrypted by address key, thus it can work only for the owner for now.
|
|
123
|
+
if (volumeId !== rootIds.volumeId) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
119
127
|
const encryptedPublicLink = await this.apiService.getPublicLink(shareId);
|
|
120
128
|
if (!encryptedPublicLink) {
|
|
121
129
|
return;
|
|
122
130
|
}
|
|
131
|
+
|
|
123
132
|
return this.cryptoService.decryptPublicLink(encryptedPublicLink);
|
|
124
133
|
}
|
|
125
134
|
|
|
@@ -587,8 +596,52 @@ export class SharingManagement {
|
|
|
587
596
|
await this.apiService.deleteExternalInvitation(invitationUid);
|
|
588
597
|
}
|
|
589
598
|
|
|
590
|
-
|
|
591
|
-
|
|
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
|
+
};
|
|
592
645
|
}
|
|
593
646
|
|
|
594
647
|
private async removeMember(memberUid: string): Promise<void> {
|
|
@@ -604,6 +657,11 @@ export class SharingManagement {
|
|
|
604
657
|
share: Share,
|
|
605
658
|
options: SharePublicLinkSettingsObject,
|
|
606
659
|
): Promise<PublicLinkWithCreatorEmail> {
|
|
660
|
+
const rootIds = await this.sharesService.getRootIDs();
|
|
661
|
+
if (share.volumeId !== rootIds.volumeId) {
|
|
662
|
+
throw new ValidationError(c('Error').t`Cannot create public link for volume not owned by the user`);
|
|
663
|
+
}
|
|
664
|
+
|
|
607
665
|
const generatedPassword = await this.cryptoService.generatePublicLinkPassword();
|
|
608
666
|
const password = options.customPassword ? `${generatedPassword}${options.customPassword}` : generatedPassword;
|
|
609
667
|
|
|
@@ -638,6 +696,11 @@ export class SharingManagement {
|
|
|
638
696
|
publicLink: PublicLinkWithCreatorEmail,
|
|
639
697
|
options: SharePublicLinkSettingsObject,
|
|
640
698
|
): Promise<PublicLinkWithCreatorEmail> {
|
|
699
|
+
const rootIds = await this.sharesService.getRootIDs();
|
|
700
|
+
if (share.volumeId !== rootIds.volumeId) {
|
|
701
|
+
throw new ValidationError(c('Error').t`Cannot update public link for volume not owned by the user`);
|
|
702
|
+
}
|
|
703
|
+
|
|
641
704
|
const generatedPassword = publicLink.url.split('#')[1];
|
|
642
705
|
// Legacy public links didn't have generated password or had various lengths.
|
|
643
706
|
if (!generatedPassword || generatedPassword.length !== PUBLIC_LINK_GENERATED_PASSWORD_LENGTH) {
|
|
@@ -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
|
*
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
ShareNodeSettings,
|
|
14
14
|
ShareResult,
|
|
15
15
|
UnshareNodeSettings,
|
|
16
|
+
ProtonInvitation,
|
|
16
17
|
ProtonInvitationOrUid,
|
|
17
18
|
NonProtonInvitationOrUid,
|
|
18
19
|
ProtonInvitationWithNode,
|
|
@@ -408,6 +409,22 @@ export class ProtonDrivePhotosClient {
|
|
|
408
409
|
return this.sharing.management.unshareNode(getUid(nodeUid), settings);
|
|
409
410
|
}
|
|
410
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Convert a non-Proton invitation to an internal invitation.
|
|
414
|
+
* This is called automatically in the background when the SDK receives
|
|
415
|
+
* a metadata update event, but can also be triggered manually.
|
|
416
|
+
*
|
|
417
|
+
* @param nodeUid - Node entity or its UID string.
|
|
418
|
+
* @param invitationOrUid - Non-Proton invitation entity or its UID string.
|
|
419
|
+
*/
|
|
420
|
+
async convertNonProtonInvitation(
|
|
421
|
+
nodeUid: NodeOrUid,
|
|
422
|
+
invitationOrUid: NonProtonInvitationOrUid,
|
|
423
|
+
): Promise<ProtonInvitation> {
|
|
424
|
+
this.logger.info(`Converting non-Proton invitation ${getUid(invitationOrUid)} for node ${getUid(nodeUid)}`);
|
|
425
|
+
return this.sharing.management.convertNonProtonInvitation(getUid(nodeUid), getUid(invitationOrUid));
|
|
426
|
+
}
|
|
427
|
+
|
|
411
428
|
/**
|
|
412
429
|
* Resend the invitation email to shared node.
|
|
413
430
|
*
|