@protontech/drive-sdk 0.15.0 → 0.15.2

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 (94) hide show
  1. package/dist/crypto/driveCrypto.d.ts +1 -1
  2. package/dist/crypto/driveCrypto.js +2 -2
  3. package/dist/crypto/driveCrypto.js.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/internal/apiService/apiService.d.ts +1 -1
  6. package/dist/internal/apiService/apiService.js +22 -7
  7. package/dist/internal/apiService/apiService.js.map +1 -1
  8. package/dist/internal/apiService/apiService.test.js +13 -0
  9. package/dist/internal/apiService/apiService.test.js.map +1 -1
  10. package/dist/internal/errors.js +35 -2
  11. package/dist/internal/errors.js.map +1 -1
  12. package/dist/internal/events/apiService.d.ts +4 -2
  13. package/dist/internal/events/apiService.js +17 -13
  14. package/dist/internal/events/apiService.js.map +1 -1
  15. package/dist/internal/events/index.d.ts +12 -1
  16. package/dist/internal/events/index.js +17 -1
  17. package/dist/internal/events/index.js.map +1 -1
  18. package/dist/internal/events/index.test.d.ts +1 -0
  19. package/dist/internal/events/index.test.js +58 -0
  20. package/dist/internal/events/index.test.js.map +1 -0
  21. package/dist/internal/nodes/cryptoService.d.ts +1 -0
  22. package/dist/internal/nodes/cryptoService.js +4 -0
  23. package/dist/internal/nodes/cryptoService.js.map +1 -1
  24. package/dist/internal/nodes/nodesAccess.test.js +2 -2
  25. package/dist/internal/nodes/nodesAccess.test.js.map +1 -1
  26. package/dist/internal/nodes/nodesManagement.js +2 -2
  27. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  28. package/dist/internal/nodes/nodesManagement.test.js +1 -1
  29. package/dist/internal/nodes/nodesManagement.test.js.map +1 -1
  30. package/dist/internal/nodes/nodesRevisions.d.ts +1 -1
  31. package/dist/internal/nodes/nodesRevisions.js +3 -0
  32. package/dist/internal/nodes/nodesRevisions.js.map +1 -1
  33. package/dist/internal/nodes/validations.d.ts +1 -1
  34. package/dist/internal/nodes/validations.js +1 -4
  35. package/dist/internal/nodes/validations.js.map +1 -1
  36. package/dist/internal/photos/addToAlbum.js +1 -1
  37. package/dist/internal/photos/addToAlbum.js.map +1 -1
  38. package/dist/internal/photos/addToAlbum.test.js +12 -12
  39. package/dist/internal/photos/addToAlbum.test.js.map +1 -1
  40. package/dist/internal/photos/albumsManager.js +1 -1
  41. package/dist/internal/photos/albumsManager.js.map +1 -1
  42. package/dist/internal/photos/albumsManager.test.js +2 -2
  43. package/dist/internal/photos/albumsManager.test.js.map +1 -1
  44. package/dist/internal/photos/apiService.d.ts +3 -3
  45. package/dist/internal/photos/apiService.js +5 -5
  46. package/dist/internal/photos/apiService.js.map +1 -1
  47. package/dist/internal/photos/apiService.test.js +4 -4
  48. package/dist/internal/photos/apiService.test.js.map +1 -1
  49. package/dist/internal/photos/photosManager.d.ts +1 -0
  50. package/dist/internal/photos/photosManager.js +38 -2
  51. package/dist/internal/photos/photosManager.js.map +1 -1
  52. package/dist/internal/photos/photosManager.test.js +26 -0
  53. package/dist/internal/photos/photosManager.test.js.map +1 -1
  54. package/dist/internal/sharing/cryptoService.js +4 -3
  55. package/dist/internal/sharing/cryptoService.js.map +1 -1
  56. package/dist/internal/sharing/cryptoService.test.js +3 -3
  57. package/dist/internal/sharing/cryptoService.test.js.map +1 -1
  58. package/dist/internal/sharingPublic/nodes.d.ts +1 -0
  59. package/dist/internal/sharingPublic/nodes.js +2 -0
  60. package/dist/internal/sharingPublic/nodes.js.map +1 -1
  61. package/dist/protonDriveClient.d.ts +14 -2
  62. package/dist/protonDriveClient.js +6 -0
  63. package/dist/protonDriveClient.js.map +1 -1
  64. package/dist/protonDrivePhotosClient.d.ts +10 -2
  65. package/dist/protonDrivePhotosClient.js +6 -0
  66. package/dist/protonDrivePhotosClient.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/crypto/driveCrypto.ts +2 -1
  69. package/src/index.ts +1 -1
  70. package/src/internal/apiService/apiService.test.ts +16 -0
  71. package/src/internal/apiService/apiService.ts +20 -2
  72. package/src/internal/errors.ts +40 -1
  73. package/src/internal/events/apiService.ts +20 -17
  74. package/src/internal/events/index.test.ts +67 -0
  75. package/src/internal/events/index.ts +20 -2
  76. package/src/internal/nodes/cryptoService.ts +6 -0
  77. package/src/internal/nodes/nodesAccess.test.ts +2 -2
  78. package/src/internal/nodes/nodesManagement.test.ts +1 -1
  79. package/src/internal/nodes/nodesManagement.ts +2 -2
  80. package/src/internal/nodes/nodesRevisions.ts +5 -1
  81. package/src/internal/nodes/validations.ts +1 -4
  82. package/src/internal/photos/addToAlbum.test.ts +12 -12
  83. package/src/internal/photos/addToAlbum.ts +1 -1
  84. package/src/internal/photos/albumsManager.test.ts +2 -2
  85. package/src/internal/photos/albumsManager.ts +1 -1
  86. package/src/internal/photos/apiService.test.ts +4 -4
  87. package/src/internal/photos/apiService.ts +6 -6
  88. package/src/internal/photos/photosManager.test.ts +36 -1
  89. package/src/internal/photos/photosManager.ts +48 -7
  90. package/src/internal/sharing/cryptoService.test.ts +3 -3
  91. package/src/internal/sharing/cryptoService.ts +4 -3
  92. package/src/internal/sharingPublic/nodes.ts +3 -0
  93. package/src/protonDriveClient.ts +18 -1
  94. package/src/protonDrivePhotosClient.ts +15 -5
@@ -58,7 +58,7 @@ async function collectSaveToTimelineResults(manager: PhotosManager, nodeUids: st
58
58
  describe('PhotosManager', () => {
59
59
  let logger: ReturnType<typeof getMockLogger>;
60
60
  let apiService: jest.Mocked<
61
- Pick<PhotosAPIService, 'addPhotoTags' | 'removePhotoTags' | 'setPhotoFavorite' | 'transferPhotos'>
61
+ Pick<PhotosAPIService, 'addPhotoTags' | 'removePhotoTags' | 'setPhotoFavorite' | 'transferPhotos' | 'copyPhoto'>
62
62
  >;
63
63
  let cryptoService: jest.Mocked<Pick<AlbumsCryptoService, 'encryptPhotoForAlbum'>>;
64
64
  let nodesService: jest.Mocked<
@@ -70,6 +70,7 @@ describe('PhotosManager', () => {
70
70
  | 'iterateNodes'
71
71
  | 'getNodePrivateAndSessionKeys'
72
72
  | 'notifyNodeChanged'
73
+ | 'notifyChildCreated'
73
74
  >
74
75
  >;
75
76
  let manager: PhotosManager;
@@ -92,6 +93,7 @@ describe('PhotosManager', () => {
92
93
  removePhotoTags: jest.fn().mockResolvedValue(undefined),
93
94
  setPhotoFavorite: jest.fn().mockResolvedValue(undefined),
94
95
  transferPhotos: jest.fn().mockImplementation(async function* () {}),
96
+ copyPhoto: jest.fn().mockResolvedValue('volume1~newPhoto'),
95
97
  };
96
98
 
97
99
  cryptoService = {
@@ -122,6 +124,7 @@ describe('PhotosManager', () => {
122
124
  passphraseSessionKey: 'passphraseSessionKey' as any,
123
125
  }),
124
126
  notifyNodeChanged: jest.fn().mockResolvedValue(undefined),
127
+ notifyChildCreated: jest.fn().mockResolvedValue(undefined),
125
128
  };
126
129
 
127
130
  manager = new PhotosManager(logger, apiService as any, cryptoService as any, nodesService as any);
@@ -304,5 +307,37 @@ describe('PhotosManager', () => {
304
307
  );
305
308
  expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1');
306
309
  });
310
+
311
+ it('copies cross-volume photo and notifies parent root folder', async () => {
312
+ apiService.copyPhoto.mockResolvedValue('volume1~newPhoto1');
313
+
314
+ const results = await collectSaveToTimelineResults(manager, ['volume2~photo1']);
315
+
316
+ expect(results).toEqual([{ uid: 'volume2~photo1', ok: true }]);
317
+ expect(apiService.copyPhoto).toHaveBeenCalledTimes(1);
318
+ expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('volume1~root');
319
+ expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
320
+ });
321
+
322
+ it('re-queues cross-volume photo once on MissingRelatedPhotosError then succeeds', async () => {
323
+ const missingRelatedUid = 'volume2~related1';
324
+ let copyCall = 0;
325
+ apiService.copyPhoto.mockImplementation(async () => {
326
+ copyCall++;
327
+ if (copyCall === 1) {
328
+ throw new MissingRelatedPhotosError([missingRelatedUid]);
329
+ }
330
+ return 'volume1~newPhoto1';
331
+ });
332
+
333
+ const results = await collectSaveToTimelineResults(manager, ['volume2~photo1']);
334
+
335
+ expect(results).toEqual([{ uid: 'volume2~photo1', ok: true }]);
336
+ expect(apiService.copyPhoto).toHaveBeenCalledTimes(2);
337
+ expect(logger.info).toHaveBeenCalledWith(
338
+ `Missing related photos for saving volume2~photo1, re-queuing: ${missingRelatedUid}`,
339
+ );
340
+ expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('volume1~root');
341
+ });
307
342
  });
308
343
  });
@@ -3,12 +3,17 @@ import { c } from 'ttag';
3
3
  import { AbortError } from '../../errors';
4
4
  import { Logger, NodeResultWithError, PhotoTag } from '../../interface';
5
5
  import { batch } from '../batch';
6
+ import { splitNodeUid } from '../uids';
6
7
  import { createBatches } from './addToAlbum';
7
8
  import { AlbumsCryptoService } from './albumsCrypto';
8
9
  import { PhotosAPIService } from './apiService';
9
10
  import { MissingRelatedPhotosError } from './errors';
10
11
  import { PhotosNodesAccess } from './nodes';
11
- import { PhotoAlreadyInTargetError, PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder';
12
+ import {
13
+ PhotoAlreadyInTargetError,
14
+ PhotoTransferPayloadBuilder,
15
+ TransferEncryptedPhotoPayload,
16
+ } from './photosTransferPayloadBuilder';
12
17
 
13
18
  /**
14
19
  * The number of photos that are loaded in parallel to prepare the payloads.
@@ -39,13 +44,14 @@ export class PhotosManager {
39
44
 
40
45
  async *saveToTimeline(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResultWithError> {
41
46
  const rootNode = await this.nodesService.getVolumeRootFolder();
47
+ const { volumeId: userVolumeId } = splitNodeUid(rootNode.uid);
42
48
  const volumeRootKeys = await this.nodesService.getNodeKeys(rootNode.uid);
43
49
  const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid: rootNode.uid });
44
50
 
45
- const queue: {
46
- photoNodeUid: string;
47
- additionalRelatedPhotoNodeUids: string[];
48
- }[] = nodeUids.map((nodeUid) => ({ photoNodeUid: nodeUid, additionalRelatedPhotoNodeUids: [] }));
51
+ const queue: { photoNodeUid: string; additionalRelatedPhotoNodeUids: string[] }[] = nodeUids.map((nodeUid) => ({
52
+ photoNodeUid: nodeUid,
53
+ additionalRelatedPhotoNodeUids: [],
54
+ }));
49
55
  const retriedPhotoUids = new Set<string>();
50
56
 
51
57
  while (queue.length > 0) {
@@ -62,7 +68,10 @@ export class PhotosManager {
62
68
  yield { uid, ok: false, error };
63
69
  }
64
70
 
65
- for (const batch of createBatches(payloads)) {
71
+ const sameVolumePayloads = payloads.filter((p) => splitNodeUid(p.nodeUid).volumeId === userVolumeId);
72
+ const crossVolumePayloads = payloads.filter((p) => splitNodeUid(p.nodeUid).volumeId !== userVolumeId);
73
+
74
+ for (const batch of createBatches(sameVolumePayloads)) {
66
75
  for await (const result of this.apiService.transferPhotos(rootNode.uid, batch, signal)) {
67
76
  if (
68
77
  !result.ok &&
@@ -79,16 +88,48 @@ export class PhotosManager {
79
88
  });
80
89
  continue;
81
90
  }
82
-
83
91
  if (result.ok) {
84
92
  await this.nodesService.notifyNodeChanged(result.uid);
85
93
  }
86
94
  yield result;
87
95
  }
88
96
  }
97
+
98
+ // Cross-volume photos (e.g. from shared-with-me albums): copy into the user's own
99
+ // timeline root using the generic copy endpoint.
100
+ for (const payload of crossVolumePayloads) {
101
+ try {
102
+ await this.copyPhoto(payload, signal);
103
+ await this.nodesService.notifyChildCreated(rootNode.uid);
104
+ yield { uid: payload.nodeUid, ok: true };
105
+ } catch (error) {
106
+ if (error instanceof MissingRelatedPhotosError && !retriedPhotoUids.has(payload.nodeUid)) {
107
+ retriedPhotoUids.add(payload.nodeUid);
108
+ this.logger.info(
109
+ `Missing related photos for saving ${payload.nodeUid}, re-queuing: ${error.missingNodeUids.join(', ')}`,
110
+ );
111
+ queue.push({
112
+ photoNodeUid: payload.nodeUid,
113
+ additionalRelatedPhotoNodeUids: error.missingNodeUids,
114
+ });
115
+ continue;
116
+ }
117
+ yield {
118
+ uid: payload.nodeUid,
119
+ ok: false,
120
+ error:
121
+ error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }),
122
+ };
123
+ }
124
+ }
89
125
  }
90
126
  }
91
127
 
128
+ private async copyPhoto(payload: TransferEncryptedPhotoPayload, signal?: AbortSignal): Promise<string> {
129
+ const rootNode = await this.nodesService.getVolumeRootFolder();
130
+ return this.apiService.copyPhoto(rootNode.uid, payload, signal);
131
+ }
132
+
92
133
  async *updatePhotos(photos: UpdatePhotoSettings[], signal?: AbortSignal): AsyncGenerator<NodeResultWithError> {
93
134
  for await (const {
94
135
  photoSettings: { nodeUid, tagsToAdd, tagsToRemove },
@@ -169,7 +169,7 @@ describe('SharingCryptoService', () => {
169
169
 
170
170
  it('should handle invalid node name', async () => {
171
171
  driveCrypto.decryptNodeName = jest.fn().mockResolvedValue({
172
- name: 'invalid/name',
172
+ name: '',
173
173
  });
174
174
 
175
175
  const result = await cryptoService.decryptBookmark(encryptedBookmark);
@@ -177,8 +177,8 @@ describe('SharingCryptoService', () => {
177
177
  expect(result).toMatchObject({
178
178
  url: resultOk('https://drive.proton.me/urls/tokenId#urlPassword'),
179
179
  nodeName: resultError({
180
- name: 'invalid/name',
181
- error: "Name must not contain the character '/'",
180
+ name: '',
181
+ error: "Name must not be empty",
182
182
  }),
183
183
  });
184
184
  });
@@ -193,12 +193,12 @@ export class SharingCryptoService {
193
193
  encryptedInvitation: EncryptedInvitationWithNode,
194
194
  ): Promise<ProtonInvitationWithNode> {
195
195
  const inviteeAddress = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail);
196
- const inviteeKey = inviteeAddress.keys[inviteeAddress.primaryKeyIndex].key;
196
+ const inviteeKeys = inviteeAddress.keys.map(k => k.key);
197
197
 
198
198
  const shareKey = await this.driveCrypto.decryptUnsignedKey(
199
199
  encryptedInvitation.share.armoredKey,
200
200
  encryptedInvitation.share.armoredPassphrase,
201
- inviteeKey,
201
+ inviteeKeys,
202
202
  );
203
203
 
204
204
  let nodeName: Result<string, Error>;
@@ -246,7 +246,8 @@ export class SharingCryptoService {
246
246
  }> {
247
247
  const inviteeAddress = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail);
248
248
  const inviteeKey = inviteeAddress.keys[inviteeAddress.primaryKeyIndex].key;
249
- const result = await this.driveCrypto.acceptInvitation(encryptedInvitation.base64KeyPacket, inviteeKey);
249
+ const inviteeKeys = inviteeAddress.keys.map(k => k.key);
250
+ const result = await this.driveCrypto.acceptInvitation(encryptedInvitation.base64KeyPacket, inviteeKeys, inviteeKey);
250
251
  return result;
251
252
  }
252
253
 
@@ -17,6 +17,9 @@ import { makeNodeUid, splitNodeUid } from '../uids';
17
17
  import { SharingPublicSharesManager } from './shares';
18
18
 
19
19
  export class SharingPublicNodesCryptoService extends NodesCryptoService {
20
+ // Do not allow fallback verification for public links, because it is not possible to load owners' address keys.
21
+ protected allowContentKeyPacketFallbackVerification = false;
22
+
20
23
  async generateDocument(
21
24
  parentKeys: { key: PrivateKey; hashKey: Uint8Array<ArrayBuffer> },
22
25
  signingKeys: NodeSigningKeys,
@@ -6,6 +6,7 @@ import {
6
6
  Device,
7
7
  DeviceOrUid,
8
8
  DeviceType,
9
+ DriveEvent,
9
10
  FileDownloader,
10
11
  FileUploader,
11
12
  Logger,
@@ -36,7 +37,7 @@ import {
36
37
  import { DriveAPIService } from './internal/apiService';
37
38
  import { initDevicesModule } from './internal/devices';
38
39
  import { initDownloadModule } from './internal/download';
39
- import { DriveEventsService, DriveListener, EventSubscription } from './internal/events';
40
+ import { CoreApiEvent, DriveEventsService, DriveListener, EventSubscription } from './internal/events';
40
41
  import { initNodesModule } from './internal/nodes';
41
42
  import { SDKEvents } from './internal/sdkEvents';
42
43
  import { initSharesModule } from './internal/shares';
@@ -113,6 +114,16 @@ export class ProtonDriveClient {
113
114
  customPassword?: string,
114
115
  isAnonymousContext?: boolean,
115
116
  ) => Promise<ProtonDrivePublicLinkClient>;
117
+ /**
118
+ * Feed a raw core API event response into the SDK.
119
+ *
120
+ * The SDK will derive drive-relevant events (e.g. `SharedWithMeUpdated`)
121
+ * from it, update internal caches, and return the derived events.
122
+ *
123
+ * The `rawEvent` shape matches the response of the
124
+ * `core/v5/events/{id}` endpoint.
125
+ */
126
+ processCoreEvent: (rawEvent: CoreApiEvent) => Promise<DriveEvent[]>;
116
127
  };
117
128
 
118
129
  constructor({
@@ -255,6 +266,10 @@ export class ProtonDriveClient {
255
266
  session,
256
267
  });
257
268
  },
269
+ processCoreEvent: async (rawEvent: CoreApiEvent) => {
270
+ this.logger.debug(`Processing core event ${rawEvent.EventID}`);
271
+ return this.events.processCoreEvent(rawEvent);
272
+ },
258
273
  };
259
274
  }
260
275
 
@@ -293,6 +308,8 @@ export class ProtonDriveClient {
293
308
  * Subscribes to the remote general data updates.
294
309
  *
295
310
  * Only one instance of the SDK should subscribe to updates.
311
+ *
312
+ * @deprecated Use `experimental.processCoreEvent` instead.
296
313
  */
297
314
  async subscribeToDriveEvents(callback: DriveListener): Promise<EventSubscription> {
298
315
  this.logger.debug('Subscribing to core updates');
@@ -2,6 +2,7 @@ import { getConfig } from './config';
2
2
  import { DriveCrypto } from './crypto';
3
3
  import { NullFeatureFlagProvider } from './featureFlags';
4
4
  import {
5
+ DriveEvent,
5
6
  FileDownloader,
6
7
  FileUploader,
7
8
  Logger,
@@ -26,7 +27,7 @@ import {
26
27
  } from './interface';
27
28
  import { DriveAPIService } from './internal/apiService';
28
29
  import { initDownloadModule } from './internal/download';
29
- import { DriveEventsService, DriveListener, EventSubscription } from './internal/events';
30
+ import { CoreApiEvent, DriveEventsService, DriveListener, EventSubscription } from './internal/events';
30
31
  import {
31
32
  AlbumItem,
32
33
  initPhotoSharesModule,
@@ -82,6 +83,12 @@ export class ProtonDrivePhotosClient {
82
83
  * @param signal - An optional abort signal to cancel the operation.
83
84
  */
84
85
  iterateAlbumUids: (signal?: AbortSignal) => AsyncGenerator<string>;
86
+ /**
87
+ * Feed a raw core API event response into the SDK.
88
+ *
89
+ * See `ProtonDriveClient.experimental.processCoreEvent` for more information.
90
+ */
91
+ processCoreEvent: (rawEvent: CoreApiEvent) => Promise<DriveEvent[]>;
85
92
  };
86
93
 
87
94
  constructor({
@@ -186,6 +193,10 @@ export class ProtonDrivePhotosClient {
186
193
  this.logger.debug('Iterating album UIDs');
187
194
  return this.photos.albums.iterateAlbumUids(signal);
188
195
  },
196
+ processCoreEvent: async (rawEvent: CoreApiEvent) => {
197
+ this.logger.debug(`Processing core event ${rawEvent.EventID}`);
198
+ return this.events.processCoreEvent(rawEvent);
199
+ },
189
200
  };
190
201
  }
191
202
 
@@ -213,6 +224,8 @@ export class ProtonDrivePhotosClient {
213
224
  * Subscribes to the remote general data updates.
214
225
  *
215
226
  * See `ProtonDriveClient.subscribeToDriveEvents` for more information.
227
+ *
228
+ * @deprecated Use `experimental.processCoreEvent` instead.
216
229
  */
217
230
  async subscribeToDriveEvents(callback: DriveListener): Promise<EventSubscription> {
218
231
  this.logger.debug('Subscribing to core updates');
@@ -707,10 +720,7 @@ export class ProtonDrivePhotosClient {
707
720
  * @param signal - An optional abort signal to cancel the operation.
708
721
  * @returns An async generator of per-photo results.
709
722
  */
710
- async *savePhotosToTimeline(
711
- photoNodeUids: NodeOrUid[],
712
- signal?: AbortSignal,
713
- ): AsyncGenerator<NodeResultWithError> {
723
+ async *savePhotosToTimeline(photoNodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator<NodeResultWithError> {
714
724
  this.logger.info(`Saving ${photoNodeUids.length} photos to timeline`);
715
725
  yield* this.photos.photos.saveToTimeline(getUids(photoNodeUids), signal);
716
726
  }