@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.
Files changed (49) hide show
  1. package/dist/interface/account.d.ts +2 -1
  2. package/dist/internal/nodes/cryptoService.d.ts +3 -2
  3. package/dist/internal/nodes/cryptoService.js +11 -1
  4. package/dist/internal/nodes/cryptoService.js.map +1 -1
  5. package/dist/internal/nodes/cryptoService.test.js +40 -2
  6. package/dist/internal/nodes/cryptoService.test.js.map +1 -1
  7. package/dist/internal/nodes/index.js +1 -1
  8. package/dist/internal/nodes/index.js.map +1 -1
  9. package/dist/internal/nodes/nodesManagement.d.ts +2 -2
  10. package/dist/internal/nodes/nodesManagement.js +1 -1
  11. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  12. package/dist/internal/nodes/nodesManagement.test.js +30 -0
  13. package/dist/internal/nodes/nodesManagement.test.js.map +1 -1
  14. package/dist/internal/photos/index.js +1 -1
  15. package/dist/internal/photos/index.js.map +1 -1
  16. package/dist/internal/sharing/apiService.d.ts +1 -1
  17. package/dist/internal/sharing/apiService.js +2 -2
  18. package/dist/internal/sharing/apiService.js.map +1 -1
  19. package/dist/internal/sharing/cryptoService.d.ts +1 -1
  20. package/dist/internal/sharing/cryptoService.js +2 -2
  21. package/dist/internal/sharing/cryptoService.js.map +1 -1
  22. package/dist/internal/sharing/sharingManagement.d.ts +2 -2
  23. package/dist/internal/sharing/sharingManagement.js +45 -4
  24. package/dist/internal/sharing/sharingManagement.js.map +1 -1
  25. package/dist/internal/sharing/sharingManagement.test.js +74 -4
  26. package/dist/internal/sharing/sharingManagement.test.js.map +1 -1
  27. package/dist/internal/sharingPublic/index.js +1 -1
  28. package/dist/internal/sharingPublic/index.js.map +1 -1
  29. package/dist/protonDriveClient.d.ts +11 -2
  30. package/dist/protonDriveClient.js +12 -0
  31. package/dist/protonDriveClient.js.map +1 -1
  32. package/dist/protonDrivePhotosClient.d.ts +10 -1
  33. package/dist/protonDrivePhotosClient.js +12 -0
  34. package/dist/protonDrivePhotosClient.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/interface/account.ts +2 -1
  37. package/src/internal/nodes/cryptoService.test.ts +52 -2
  38. package/src/internal/nodes/cryptoService.ts +16 -1
  39. package/src/internal/nodes/index.ts +1 -1
  40. package/src/internal/nodes/nodesManagement.test.ts +38 -2
  41. package/src/internal/nodes/nodesManagement.ts +15 -3
  42. package/src/internal/photos/index.ts +1 -1
  43. package/src/internal/sharing/apiService.ts +2 -1
  44. package/src/internal/sharing/cryptoService.ts +2 -1
  45. package/src/internal/sharing/sharingManagement.test.ts +104 -4
  46. package/src/internal/sharing/sharingManagement.ts +68 -5
  47. package/src/internal/sharingPublic/index.ts +7 -1
  48. package/src/protonDriveClient.ts +19 -1
  49. 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(encryptedNode, parentKey);
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(parentHashKey: Uint8Array<ArrayBuffer>, names: string[]): Promise<{ name: string; hash: string }[]> {
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 { MemberRole, NodeType, NodeResult, NodeResultWithNewUid, resultOk, InvalidNameError } from '../../interface';
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(nodeUids: string[], newParentNodeUid: string, signal?: AbortSignal): AsyncGenerator<NodeResult> {
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: null,
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
- private async convertExternalInvitationsToInternal(): Promise<void> {
591
- // FIXME
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(telemetry, driveCrypto, account, cryptoReporter);
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,
@@ -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<NodeResult> {
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
  *