@protontech/drive-sdk 0.3.2 → 0.4.0

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 (120) hide show
  1. package/dist/internal/apiService/errorCodes.d.ts +1 -0
  2. package/dist/internal/apiService/errors.d.ts +3 -0
  3. package/dist/internal/apiService/errors.js +7 -1
  4. package/dist/internal/apiService/errors.js.map +1 -1
  5. package/dist/internal/devices/interface.d.ts +1 -1
  6. package/dist/internal/devices/manager.js +1 -1
  7. package/dist/internal/devices/manager.js.map +1 -1
  8. package/dist/internal/devices/manager.test.js +3 -3
  9. package/dist/internal/devices/manager.test.js.map +1 -1
  10. package/dist/internal/events/apiService.js +1 -1
  11. package/dist/internal/events/apiService.js.map +1 -1
  12. package/dist/internal/events/coreEventManager.js +1 -1
  13. package/dist/internal/events/coreEventManager.js.map +1 -1
  14. package/dist/internal/events/coreEventManager.test.js +18 -24
  15. package/dist/internal/events/coreEventManager.test.js.map +1 -1
  16. package/dist/internal/events/index.d.ts +3 -4
  17. package/dist/internal/events/index.js +4 -4
  18. package/dist/internal/events/index.js.map +1 -1
  19. package/dist/internal/events/interface.d.ts +3 -0
  20. package/dist/internal/nodes/apiService.d.ts +12 -3
  21. package/dist/internal/nodes/apiService.js +53 -13
  22. package/dist/internal/nodes/apiService.js.map +1 -1
  23. package/dist/internal/nodes/apiService.test.js +19 -2
  24. package/dist/internal/nodes/apiService.test.js.map +1 -1
  25. package/dist/internal/nodes/cryptoService.d.ts +1 -1
  26. package/dist/internal/nodes/cryptoService.js +1 -1
  27. package/dist/internal/nodes/cryptoService.js.map +1 -1
  28. package/dist/internal/nodes/cryptoService.test.js +4 -4
  29. package/dist/internal/nodes/cryptoService.test.js.map +1 -1
  30. package/dist/internal/nodes/errors.d.ts +4 -0
  31. package/dist/internal/nodes/errors.js +9 -0
  32. package/dist/internal/nodes/errors.js.map +1 -0
  33. package/dist/internal/nodes/index.test.js +1 -1
  34. package/dist/internal/nodes/index.test.js.map +1 -1
  35. package/dist/internal/nodes/interface.d.ts +4 -1
  36. package/dist/internal/nodes/nodesAccess.d.ts +3 -3
  37. package/dist/internal/nodes/nodesAccess.js +25 -15
  38. package/dist/internal/nodes/nodesAccess.js.map +1 -1
  39. package/dist/internal/nodes/nodesAccess.test.js +48 -8
  40. package/dist/internal/nodes/nodesAccess.test.js.map +1 -1
  41. package/dist/internal/nodes/nodesManagement.d.ts +2 -0
  42. package/dist/internal/nodes/nodesManagement.js +86 -9
  43. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  44. package/dist/internal/nodes/nodesManagement.test.js +81 -5
  45. package/dist/internal/nodes/nodesManagement.test.js.map +1 -1
  46. package/dist/internal/photos/albums.d.ts +9 -7
  47. package/dist/internal/photos/albums.js +26 -13
  48. package/dist/internal/photos/albums.js.map +1 -1
  49. package/dist/internal/photos/apiService.d.ts +34 -3
  50. package/dist/internal/photos/apiService.js +96 -3
  51. package/dist/internal/photos/apiService.js.map +1 -1
  52. package/dist/internal/photos/index.d.ts +20 -4
  53. package/dist/internal/photos/index.js +30 -7
  54. package/dist/internal/photos/index.js.map +1 -1
  55. package/dist/internal/photos/interface.d.ts +25 -1
  56. package/dist/internal/photos/shares.d.ts +43 -0
  57. package/dist/internal/photos/shares.js +112 -0
  58. package/dist/internal/photos/shares.js.map +1 -0
  59. package/dist/internal/photos/timeline.d.ts +15 -0
  60. package/dist/internal/photos/timeline.js +22 -0
  61. package/dist/internal/photos/timeline.js.map +1 -0
  62. package/dist/internal/shares/manager.d.ts +1 -1
  63. package/dist/internal/shares/manager.js +4 -4
  64. package/dist/internal/shares/manager.js.map +1 -1
  65. package/dist/internal/shares/manager.test.js +7 -7
  66. package/dist/internal/shares/manager.test.js.map +1 -1
  67. package/dist/internal/sharing/interface.d.ts +1 -1
  68. package/dist/internal/sharing/sharingAccess.js +1 -1
  69. package/dist/internal/sharing/sharingAccess.js.map +1 -1
  70. package/dist/internal/sharing/sharingAccess.test.js +1 -1
  71. package/dist/internal/sharing/sharingAccess.test.js.map +1 -1
  72. package/dist/protonDriveClient.d.ts +20 -3
  73. package/dist/protonDriveClient.js +23 -4
  74. package/dist/protonDriveClient.js.map +1 -1
  75. package/dist/protonDrivePhotosClient.d.ts +86 -12
  76. package/dist/protonDrivePhotosClient.js +132 -29
  77. package/dist/protonDrivePhotosClient.js.map +1 -1
  78. package/package.json +1 -1
  79. package/src/internal/apiService/errorCodes.ts +1 -0
  80. package/src/internal/apiService/errors.ts +6 -0
  81. package/src/internal/devices/interface.ts +1 -1
  82. package/src/internal/devices/manager.test.ts +3 -3
  83. package/src/internal/devices/manager.ts +1 -1
  84. package/src/internal/events/apiService.ts +1 -1
  85. package/src/internal/events/coreEventManager.test.ts +21 -27
  86. package/src/internal/events/coreEventManager.ts +1 -1
  87. package/src/internal/events/index.ts +3 -4
  88. package/src/internal/events/interface.ts +4 -0
  89. package/src/internal/nodes/apiService.test.ts +35 -1
  90. package/src/internal/nodes/apiService.ts +103 -17
  91. package/src/internal/nodes/cryptoService.test.ts +8 -8
  92. package/src/internal/nodes/cryptoService.ts +1 -1
  93. package/src/internal/nodes/errors.ts +5 -0
  94. package/src/internal/nodes/index.test.ts +1 -1
  95. package/src/internal/nodes/interface.ts +5 -1
  96. package/src/internal/nodes/nodesAccess.test.ts +68 -8
  97. package/src/internal/nodes/nodesAccess.ts +42 -15
  98. package/src/internal/nodes/nodesManagement.test.ts +100 -5
  99. package/src/internal/nodes/nodesManagement.ts +100 -13
  100. package/src/internal/photos/albums.ts +31 -12
  101. package/src/internal/photos/apiService.ts +159 -4
  102. package/src/internal/photos/index.ts +54 -9
  103. package/src/internal/photos/interface.ts +23 -1
  104. package/src/internal/photos/shares.ts +134 -0
  105. package/src/internal/photos/timeline.ts +24 -0
  106. package/src/internal/shares/manager.test.ts +7 -7
  107. package/src/internal/shares/manager.ts +4 -4
  108. package/src/internal/sharing/interface.ts +1 -1
  109. package/src/internal/sharing/sharingAccess.test.ts +1 -1
  110. package/src/internal/sharing/sharingAccess.ts +1 -1
  111. package/src/protonDriveClient.ts +33 -4
  112. package/src/protonDrivePhotosClient.ts +211 -32
  113. package/dist/internal/photos/cache.d.ts +0 -6
  114. package/dist/internal/photos/cache.js +0 -15
  115. package/dist/internal/photos/cache.js.map +0 -1
  116. package/dist/internal/photos/photosTimeline.d.ts +0 -10
  117. package/dist/internal/photos/photosTimeline.js +0 -19
  118. package/dist/internal/photos/photosTimeline.js.map +0 -1
  119. package/src/internal/photos/cache.ts +0 -11
  120. package/src/internal/photos/photosTimeline.ts +0 -17
@@ -5,6 +5,7 @@ import { NodesAccess } from './nodesAccess';
5
5
  import { DecryptedNode } from './interface';
6
6
  import { NodesManagement } from './nodesManagement';
7
7
  import { NodeResult } from '../../interface';
8
+ import { NodeOutOfSyncError } from './errors';
8
9
 
9
10
  describe('NodesManagement', () => {
10
11
  let apiService: NodeAPIService;
@@ -49,6 +50,7 @@ describe('NodesManagement', () => {
49
50
  apiService = {
50
51
  renameNode: jest.fn(),
51
52
  moveNode: jest.fn(),
53
+ copyNode: jest.fn(),
52
54
  trashNodes: jest.fn(async function* (uids) {
53
55
  yield* uids.map((uid) => ({ ok: true, uid }) as NodeResult);
54
56
  }),
@@ -71,7 +73,7 @@ describe('NodesManagement', () => {
71
73
  armoredNodeName: 'newArmoredNodeName',
72
74
  hash: 'newHash',
73
75
  }),
74
- moveNode: jest.fn(),
76
+ encryptNodeWithNewParent: jest.fn(),
75
77
  createFolder: jest.fn(),
76
78
  };
77
79
  // @ts-expect-error No need to implement all methods for mocking
@@ -100,6 +102,7 @@ describe('NodesManagement', () => {
100
102
  getRootNodeEmailKey: jest.fn().mockResolvedValue({ email: 'root-email', addressKey: 'root-key' }),
101
103
  notifyNodeChanged: jest.fn(),
102
104
  notifyNodeDeleted: jest.fn(),
105
+ notifyChildCreated: jest.fn(),
103
106
  };
104
107
 
105
108
  management = new NodesManagement(apiService, cryptoCache, cryptoService, nodesAccess);
@@ -130,6 +133,15 @@ describe('NodesManagement', () => {
130
133
  expect(nodesAccess.notifyNodeChanged).toHaveBeenCalledWith('nodeUid');
131
134
  });
132
135
 
136
+ it('renameNode refreshes cache if node is out of sync', async () => {
137
+ const error = new NodeOutOfSyncError('Node is out of sync');
138
+ apiService.renameNode = jest.fn().mockRejectedValue(error);
139
+
140
+ await expect(management.renameNode('nodeUid', 'new name')).rejects.toThrow(error);
141
+
142
+ expect(nodesAccess.notifyNodeChanged).toHaveBeenCalledWith('nodeUid');
143
+ });
144
+
133
145
  it('moveNode manages move and updates cache', async () => {
134
146
  const encryptedCrypto = {
135
147
  encryptedName: 'movedArmoredNodeName',
@@ -139,7 +151,7 @@ describe('NodesManagement', () => {
139
151
  signatureEmail: 'movedSignatureEmail',
140
152
  nameSignatureEmail: 'movedNameSignatureEmail',
141
153
  };
142
- cryptoService.moveNode = jest.fn().mockResolvedValue(encryptedCrypto);
154
+ cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto);
143
155
 
144
156
  const newNode = await management.moveNode('nodeUid', 'newParentNodeUid');
145
157
 
@@ -152,7 +164,7 @@ describe('NodesManagement', () => {
152
164
  nameAuthor: { ok: true, value: 'movedNameSignatureEmail' },
153
165
  });
154
166
  expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('newParentNodeUid');
155
- expect(cryptoService.moveNode).toHaveBeenCalledWith(
167
+ expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith(
156
168
  nodes.nodeUid,
157
169
  expect.objectContaining({
158
170
  key: 'nodeUid-key',
@@ -188,11 +200,11 @@ describe('NodesManagement', () => {
188
200
  signatureEmail: 'movedSignatureEmail',
189
201
  nameSignatureEmail: 'movedNameSignatureEmail',
190
202
  };
191
- cryptoService.moveNode = jest.fn().mockResolvedValue(encryptedCrypto);
203
+ cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto);
192
204
 
193
205
  const newNode = await management.moveNode('anonymousNodeUid', 'newParentNodeUid');
194
206
 
195
- expect(cryptoService.moveNode).toHaveBeenCalledWith(
207
+ expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith(
196
208
  nodes.anonymousNodeUid,
197
209
  expect.objectContaining({
198
210
  key: 'anonymousNodeUid-key',
@@ -224,6 +236,89 @@ describe('NodesManagement', () => {
224
236
  );
225
237
  });
226
238
 
239
+ it('copyNode manages copy and updates cache', async () => {
240
+ const encryptedCrypto = {
241
+ encryptedName: 'copiedArmoredNodeName',
242
+ hash: 'copiedHash',
243
+ armoredNodePassphrase: 'copiedArmoredNodePassphrase',
244
+ armoredNodePassphraseSignature: 'copiedArmoredNodePassphraseSignature',
245
+ signatureEmail: 'copiedSignatureEmail',
246
+ nameSignatureEmail: 'copiedNameSignatureEmail',
247
+ };
248
+ cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto);
249
+
250
+ const newNode = await management.copyNode('nodeUid', 'newParentNodeUid');
251
+
252
+ expect(newNode).toEqual({
253
+ ...nodes.nodeUid,
254
+ parentUid: 'newParentNodeUid',
255
+ encryptedName: 'copiedArmoredNodeName',
256
+ hash: 'copiedHash',
257
+ keyAuthor: { ok: true, value: 'copiedSignatureEmail' },
258
+ nameAuthor: { ok: true, value: 'copiedNameSignatureEmail' },
259
+ });
260
+ expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('newParentNodeUid');
261
+ expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith(
262
+ nodes.nodeUid,
263
+ expect.objectContaining({
264
+ key: 'nodeUid-key',
265
+ passphrase: 'nodeUid-passphrase',
266
+ passphraseSessionKey: 'nodeUid-passphraseSessionKey',
267
+ contentKeyPacketSessionKey: 'nodeUid-contentKeyPacketSessionKey',
268
+ nameSessionKey: 'nodeUid-nameSessionKey',
269
+ }),
270
+ expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }),
271
+ { email: 'root-email', addressKey: 'root-key' },
272
+ );
273
+ expect(apiService.copyNode).toHaveBeenCalledWith('nodeUid', {
274
+ parentUid: 'newParentNodeUid',
275
+ ...encryptedCrypto,
276
+ armoredNodePassphraseSignature: undefined,
277
+ signatureEmail: undefined,
278
+ });
279
+ expect(nodesAccess.notifyNodeChanged).not.toHaveBeenCalledWith();
280
+ expect(nodesAccess.notifyChildCreated).toHaveBeenCalledWith('newParentNodeUid');
281
+ });
282
+
283
+ it('copyNode manages copy of anonymous node', async () => {
284
+ const encryptedCrypto = {
285
+ encryptedName: 'copiedArmoredNodeName',
286
+ hash: 'copiedHash',
287
+ armoredNodePassphrase: 'copiedArmoredNodePassphrase',
288
+ armoredNodePassphraseSignature: 'copiedArmoredNodePassphraseSignature',
289
+ signatureEmail: 'copiedSignatureEmail',
290
+ nameSignatureEmail: 'copiedNameSignatureEmail',
291
+ };
292
+ cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto);
293
+
294
+ const newNode = await management.copyNode('anonymousNodeUid', 'newParentNodeUid');
295
+
296
+ expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith(
297
+ nodes.anonymousNodeUid,
298
+ expect.objectContaining({
299
+ key: 'anonymousNodeUid-key',
300
+ passphrase: 'anonymousNodeUid-passphrase',
301
+ passphraseSessionKey: 'anonymousNodeUid-passphraseSessionKey',
302
+ contentKeyPacketSessionKey: 'anonymousNodeUid-contentKeyPacketSessionKey',
303
+ nameSessionKey: 'anonymousNodeUid-nameSessionKey',
304
+ }),
305
+ expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }),
306
+ { email: 'root-email', addressKey: 'root-key' },
307
+ );
308
+ expect(newNode).toEqual({
309
+ ...nodes.anonymousNodeUid,
310
+ parentUid: 'newParentNodeUid',
311
+ encryptedName: 'copiedArmoredNodeName',
312
+ hash: 'copiedHash',
313
+ keyAuthor: { ok: true, value: 'copiedSignatureEmail' },
314
+ nameAuthor: { ok: true, value: 'copiedNameSignatureEmail' },
315
+ });
316
+ expect(apiService.copyNode).toHaveBeenCalledWith('anonymousNodeUid', {
317
+ parentUid: 'newParentNodeUid',
318
+ ...encryptedCrypto,
319
+ });
320
+ });
321
+
227
322
  it('trashes node and updates cache', async () => {
228
323
  const uids = ['v1~n1', 'v1~n2'];
229
324
  const trashed = new Set();
@@ -3,14 +3,15 @@ import { c } from 'ttag';
3
3
  import { MemberRole, NodeType, NodeResult, resultOk } from '../../interface';
4
4
  import { AbortError, ValidationError } from '../../errors';
5
5
  import { getErrorMessage } from '../errors';
6
+ import { splitNodeUid } from '../uids';
6
7
  import { NodeAPIService } from './apiService';
7
8
  import { NodesCryptoCache } from './cryptoCache';
8
9
  import { NodesCryptoService } from './cryptoService';
10
+ import { NodeOutOfSyncError } from './errors';
9
11
  import { DecryptedNode } from './interface';
10
12
  import { NodesAccess } from './nodesAccess';
11
13
  import { validateNodeName } from './validations';
12
14
  import { generateFolderExtendedAttributes } from './extendedAttributes';
13
- import { splitNodeUid } from '../uids';
14
15
 
15
16
  /**
16
17
  * Provides high-level actions for managing nodes.
@@ -63,17 +64,28 @@ export class NodesManagement {
63
64
  throw new Error('Node hash not generated');
64
65
  }
65
66
 
66
- await this.apiService.renameNode(
67
- nodeUid,
68
- {
69
- hash: node.hash,
70
- },
71
- {
72
- encryptedName: armoredNodeName,
73
- nameSignatureEmail: signatureEmail,
74
- hash: hash,
75
- },
76
- );
67
+ try {
68
+ await this.apiService.renameNode(
69
+ nodeUid,
70
+ {
71
+ hash: node.hash,
72
+ },
73
+ {
74
+ encryptedName: armoredNodeName,
75
+ nameSignatureEmail: signatureEmail,
76
+ hash: hash,
77
+ },
78
+ );
79
+ } catch (error: unknown) {
80
+ // If node is out of sync, we notify cache to refresh it before next usage.
81
+ // We let the code still throw the error as it must bubble to the user
82
+ // so user can re-open the node to ensure they still want to rename it.
83
+ if (error instanceof NodeOutOfSyncError) {
84
+ await this.nodesAccess.notifyNodeChanged(nodeUid);
85
+ }
86
+ throw error;
87
+ }
88
+
77
89
  await this.nodesAccess.notifyNodeChanged(nodeUid);
78
90
  const newNode: DecryptedNode = {
79
91
  ...node,
@@ -125,7 +137,7 @@ export class NodesManagement {
125
137
  throw new ValidationError(c('Error').t`Moving item to a non-folder is not allowed`);
126
138
  }
127
139
 
128
- const encryptedCrypto = await this.cryptoService.moveNode(
140
+ const encryptedCrypto = await this.cryptoService.encryptNodeWithNewParent(
129
141
  node,
130
142
  keys,
131
143
  { key: newParentKeys.key, hashKey: newParentKeys.hashKey },
@@ -170,6 +182,81 @@ export class NodesManagement {
170
182
  return newNode;
171
183
  }
172
184
 
185
+ // Improvement requested: copy nodes in parallel
186
+ async *copyNodes(nodeUids: string[], newParentNodeUid: string, signal?: AbortSignal): AsyncGenerator<NodeResult> {
187
+ for (const nodeUid of nodeUids) {
188
+ if (signal?.aborted) {
189
+ throw new AbortError(c('Error').t`Copy operation aborted`);
190
+ }
191
+ try {
192
+ await this.copyNode(nodeUid, newParentNodeUid);
193
+ yield {
194
+ uid: nodeUid,
195
+ ok: true,
196
+ };
197
+ } catch (error: unknown) {
198
+ yield {
199
+ uid: nodeUid,
200
+ ok: false,
201
+ error: getErrorMessage(error),
202
+ };
203
+ }
204
+ }
205
+ }
206
+
207
+ async copyNode(nodeUid: string, newParentUid: string): Promise<DecryptedNode> {
208
+ const [node, address] = await Promise.all([
209
+ this.nodesAccess.getNode(nodeUid),
210
+ this.nodesAccess.getRootNodeEmailKey(newParentUid),
211
+ ]);
212
+
213
+ const [keys, newParentKeys] = await Promise.all([
214
+ this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid),
215
+ this.nodesAccess.getNodeKeys(newParentUid),
216
+ ]);
217
+
218
+ if (!newParentKeys.hashKey) {
219
+ throw new ValidationError(c('Error').t`Copying item to a non-folder is not allowed`);
220
+ }
221
+
222
+ const encryptedCrypto = await this.cryptoService.encryptNodeWithNewParent(
223
+ node,
224
+ keys,
225
+ { key: newParentKeys.key, hashKey: newParentKeys.hashKey },
226
+ address,
227
+ );
228
+
229
+ // Node could be uploaded or renamed by anonymous user and thus have
230
+ // missing signatures that must be added to the copy request.
231
+ // Node passphrase and signature email must be passed if and only if
232
+ // the the signatures are missing (key author is null).
233
+ const anonymousKey = node.keyAuthor.ok && node.keyAuthor.value === null;
234
+ const keySignatureProperties = !anonymousKey
235
+ ? {}
236
+ : {
237
+ signatureEmail: encryptedCrypto.signatureEmail,
238
+ armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature,
239
+ };
240
+ await this.apiService.copyNode(nodeUid, {
241
+ ...keySignatureProperties,
242
+ parentUid: newParentUid,
243
+ armoredNodePassphrase: encryptedCrypto.armoredNodePassphrase,
244
+ encryptedName: encryptedCrypto.encryptedName,
245
+ nameSignatureEmail: encryptedCrypto.nameSignatureEmail,
246
+ hash: encryptedCrypto.hash,
247
+ });
248
+ const newNode: DecryptedNode = {
249
+ ...node,
250
+ encryptedName: encryptedCrypto.encryptedName,
251
+ parentUid: newParentUid,
252
+ hash: encryptedCrypto.hash,
253
+ keyAuthor: resultOk(encryptedCrypto.signatureEmail),
254
+ nameAuthor: resultOk(encryptedCrypto.nameSignatureEmail),
255
+ };
256
+ await this.nodesAccess.notifyChildCreated(newParentUid);
257
+ return newNode;
258
+ }
259
+
173
260
  async *trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
174
261
  for await (const result of this.apiService.trashNodes(nodeUids, signal)) {
175
262
  if (result.ok) {
@@ -1,29 +1,48 @@
1
+ import { BatchLoading } from '../batchLoading';
2
+ import { DecryptedNode } from '../nodes';
1
3
  import { PhotosAPIService } from './apiService';
2
- import { PhotosCache } from './cache';
3
4
  import { NodesService } from './interface';
5
+ import { PhotoSharesManager } from './shares';
4
6
 
7
+ const BATCH_LOADING_SIZE = 10;
8
+
9
+ /**
10
+ * Provides access and high-level actions for managing albums.
11
+ */
5
12
  export class Albums {
6
13
  constructor(
7
14
  private apiService: PhotosAPIService,
8
- private cache: PhotosCache,
15
+ private photoShares: PhotoSharesManager,
9
16
  private nodesService: NodesService,
10
17
  ) {
11
18
  this.apiService = apiService;
12
- this.cache = cache;
19
+ this.photoShares = photoShares;
13
20
  this.nodesService = nodesService;
14
21
  }
15
22
 
16
- async *iterateAlbums() {
17
- for await (const album of this.apiService.iterateAlbums()) {
18
- const node = await this.nodesService.getNode(album.uid);
19
- yield {
20
- node,
21
- };
23
+ async *iterateAlbums(signal?: AbortSignal): AsyncGenerator<DecryptedNode> {
24
+ const { volumeId } = await this.photoShares.getOwnVolumeIDs();
25
+
26
+ const batchLoading = new BatchLoading<string, DecryptedNode>({
27
+ iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal),
28
+ batchSize: BATCH_LOADING_SIZE,
29
+ });
30
+ for await (const album of this.apiService.iterateAlbums(volumeId, signal)) {
31
+ yield* batchLoading.load(album.albumUid);
22
32
  }
33
+ yield* batchLoading.loadRest();
23
34
  }
24
35
 
25
- async createAlbum(albumName: string) {
26
- const albumdUid = this.apiService.createAlbum(albumName);
27
- await this.cache.setAlbum(albumdUid);
36
+ private async *iterateNodesAndIgnoreMissingOnes(
37
+ nodeUids: string[],
38
+ signal?: AbortSignal,
39
+ ): AsyncGenerator<DecryptedNode> {
40
+ const nodeGenerator = this.nodesService.iterateNodes(nodeUids, signal);
41
+ for await (const node of nodeGenerator) {
42
+ if ('missingUid' in node) {
43
+ continue;
44
+ }
45
+ yield node;
46
+ }
28
47
  }
29
48
  }
@@ -1,13 +1,168 @@
1
- import { DriveAPIService } from '../apiService';
1
+ import { c } from 'ttag';
2
2
 
3
+ import { DriveAPIService, drivePaths, NotFoundAPIError } from '../apiService';
4
+ import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface';
5
+ import { makeNodeUid } from '../uids';
6
+
7
+ type GetVolumesResponse = drivePaths['/drive/volumes']['get']['responses']['200']['content']['application/json'];
8
+
9
+ type GetShareResponse = drivePaths['/drive/shares/{shareID}']['get']['responses']['200']['content']['application/json'];
10
+
11
+ type PostCreateVolumeRequest = Extract<
12
+ drivePaths['/drive/photos/volumes']['post']['requestBody'],
13
+ { content: object }
14
+ >['content']['application/json'];
15
+ type PostCreateVolumeResponse =
16
+ drivePaths['/drive/photos/volumes']['post']['responses']['200']['content']['application/json'];
17
+
18
+ type GetTimelineResponse =
19
+ drivePaths['/drive/volumes/{volumeID}/photos']['get']['responses']['200']['content']['application/json'];
20
+
21
+ type GetAlbumsResponse =
22
+ drivePaths['/drive/photos/volumes/{volumeID}/albums']['get']['responses']['200']['content']['application/json'];
23
+
24
+ /**
25
+ * Provides API communication for fetching and manipulating photos and albums
26
+ * metadata.
27
+ *
28
+ * The service is responsible for transforming local objects to API payloads
29
+ * and vice versa. It should not contain any business logic.
30
+ */
3
31
  export class PhotosAPIService {
4
32
  constructor(private apiService: DriveAPIService) {
5
33
  this.apiService = apiService;
6
34
  }
7
35
 
8
- async *iterateTimeline(): AsyncGenerator<any> {}
36
+ async getPhotoShare(): Promise<EncryptedRootShare> {
37
+ // TODO: Switch to drive/v2/shares/photos once available.
38
+
39
+ const volumesResponse = await this.apiService.get<GetVolumesResponse>('drive/volumes');
40
+
41
+ const photoVolume = volumesResponse.Volumes.find((volume) => volume.Type === 2);
42
+
43
+ if (!photoVolume) {
44
+ throw new NotFoundAPIError(c('Error').t`Photo volume not found`);
45
+ }
46
+
47
+ const response = await this.apiService.get<GetShareResponse>(`drive/shares/${photoVolume.Share.ShareID}`);
9
48
 
10
- async *iterateAlbums(): AsyncGenerator<any> {}
49
+ if (!response.AddressID) {
50
+ throw new Error('Photo root share has not address ID set');
51
+ }
11
52
 
12
- async createAlbum(object: any): Promise<any> {}
53
+ return {
54
+ volumeId: response.VolumeID,
55
+ shareId: response.ShareID,
56
+ rootNodeId: response.LinkID,
57
+ creatorEmail: response.Creator,
58
+ encryptedCrypto: {
59
+ armoredKey: response.Key,
60
+ armoredPassphrase: response.Passphrase,
61
+ armoredPassphraseSignature: response.PassphraseSignature,
62
+ },
63
+ addressId: response.AddressID,
64
+ type: ShareType.Photo,
65
+ };
66
+ }
67
+
68
+ async createPhotoVolume(
69
+ share: {
70
+ addressId: string;
71
+ addressKeyId: string;
72
+ } & EncryptedShareCrypto,
73
+ node: {
74
+ encryptedName: string;
75
+ armoredKey: string;
76
+ armoredPassphrase: string;
77
+ armoredPassphraseSignature: string;
78
+ armoredHashKey: string;
79
+ },
80
+ ): Promise<{ volumeId: string; shareId: string; rootNodeId: string }> {
81
+ const response = await this.apiService.post<PostCreateVolumeRequest, PostCreateVolumeResponse>(
82
+ 'drive/photos/volumes',
83
+ {
84
+ Share: {
85
+ AddressID: share.addressId,
86
+ AddressKeyID: share.addressKeyId,
87
+ Key: share.armoredKey,
88
+ Passphrase: share.armoredPassphrase,
89
+ PassphraseSignature: share.armoredPassphraseSignature,
90
+ },
91
+ Link: {
92
+ Name: node.encryptedName,
93
+ NodeKey: node.armoredKey,
94
+ NodePassphrase: node.armoredPassphrase,
95
+ NodePassphraseSignature: node.armoredPassphraseSignature,
96
+ NodeHashKey: node.armoredHashKey,
97
+ },
98
+ },
99
+ );
100
+ return {
101
+ volumeId: response.Volume.VolumeID,
102
+ shareId: response.Volume.Share.ShareID,
103
+ rootNodeId: response.Volume.Share.LinkID,
104
+ };
105
+ }
106
+
107
+ async *iterateTimeline(
108
+ volumeId: string,
109
+ signal?: AbortSignal,
110
+ ): AsyncGenerator<{
111
+ nodeUid: string;
112
+ captureTime: Date;
113
+ tags: number[];
114
+ }> {
115
+ let anchor = '';
116
+ while (true) {
117
+ const response = await this.apiService.get<GetTimelineResponse>(
118
+ `drive/volumes/${volumeId}/photos?${anchor ? `PreviousPageLastLinkID=${anchor}` : ''}`,
119
+ signal,
120
+ );
121
+ for (const photo of response.Photos) {
122
+ const nodeUid = makeNodeUid(volumeId, photo.LinkID);
123
+ yield {
124
+ nodeUid,
125
+ captureTime: new Date(photo.CaptureTime * 1000),
126
+ tags: photo.Tags,
127
+ };
128
+ }
129
+
130
+ if (!response.Photos.length) {
131
+ break;
132
+ }
133
+ anchor = response.Photos[response.Photos.length - 1].LinkID;
134
+ }
135
+ }
136
+
137
+ async *iterateAlbums(
138
+ volumeId: string,
139
+ signal?: AbortSignal,
140
+ ): AsyncGenerator<{
141
+ albumUid: string;
142
+ coverNodeUid?: string;
143
+ photoCount: number;
144
+ lastActivityTime: Date;
145
+ }> {
146
+ let anchor = '';
147
+ while (true) {
148
+ const response = await this.apiService.get<GetAlbumsResponse>(
149
+ `drive/photos/volumes/${volumeId}/albums?${anchor ? `AnchorID=${anchor}` : ''}`,
150
+ signal,
151
+ );
152
+ for (const album of response.Albums) {
153
+ const albumUid = makeNodeUid(volumeId, album.LinkID);
154
+ yield {
155
+ albumUid,
156
+ coverNodeUid: album.CoverLinkID ? makeNodeUid(volumeId, album.CoverLinkID) : undefined,
157
+ photoCount: album.PhotoCount,
158
+ lastActivityTime: new Date(album.LastActivityTime * 1000),
159
+ };
160
+ }
161
+
162
+ if (!response.More || !response.AnchorID) {
163
+ break;
164
+ }
165
+ anchor = response.AnchorID;
166
+ }
167
+ }
13
168
  }
@@ -1,23 +1,68 @@
1
1
  import { DriveAPIService } from '../apiService';
2
- import { ProtonDriveEntitiesCache } from '../../interface';
3
- import { PhotosAPIService } from './apiService';
4
- import { PhotosCache } from './cache';
5
- import { PhotosTimeline } from './photosTimeline';
2
+ import { DriveCrypto } from '../../crypto';
3
+ import {
4
+ ProtonDriveAccount,
5
+ ProtonDriveCryptoCache,
6
+ ProtonDriveEntitiesCache,
7
+ ProtonDriveTelemetry,
8
+ } from '../../interface';
9
+ import { SharesCache } from '../shares/cache';
10
+ import { SharesCryptoCache } from '../shares/cryptoCache';
11
+ import { SharesCryptoService } from '../shares/cryptoService';
6
12
  import { Albums } from './albums';
7
- import { NodesService } from './interface';
13
+ import { PhotosAPIService } from './apiService';
14
+ import { NodesService, SharesService } from './interface';
15
+ import { PhotoSharesManager } from './shares';
16
+ import { PhotosTimeline } from './timeline';
8
17
 
18
+ /**
19
+ * Provides facade for the whole photos module.
20
+ *
21
+ * The photos module is responsible for handling photos and albums metadata,
22
+ * including API communication, crypto, caching, and event handling.
23
+ */
9
24
  export function initPhotosModule(
10
25
  apiService: DriveAPIService,
11
- driveEntitiesCache: ProtonDriveEntitiesCache,
26
+ photoShares: PhotoSharesManager,
12
27
  nodesService: NodesService,
13
28
  ) {
14
29
  const api = new PhotosAPIService(apiService);
15
- const cache = new PhotosCache(driveEntitiesCache);
16
- const timeline = new PhotosTimeline(api, cache, nodesService);
17
- const albums = new Albums(api, cache, nodesService);
30
+ const timeline = new PhotosTimeline(api, photoShares);
31
+ const albums = new Albums(api, photoShares, nodesService);
18
32
 
19
33
  return {
20
34
  timeline,
21
35
  albums,
22
36
  };
23
37
  }
38
+
39
+ /**
40
+ * Provides facade for the photo share module.
41
+ *
42
+ * The photo share wraps the core share module, but uses photos volume instead
43
+ * of main volume. It provides the same interface so it can be used in the same
44
+ * way in various modules that use shares.
45
+ */
46
+ export function initPhotoSharesModule(
47
+ telemetry: ProtonDriveTelemetry,
48
+ apiService: DriveAPIService,
49
+ driveEntitiesCache: ProtonDriveEntitiesCache,
50
+ driveCryptoCache: ProtonDriveCryptoCache,
51
+ account: ProtonDriveAccount,
52
+ crypto: DriveCrypto,
53
+ sharesService: SharesService,
54
+ ) {
55
+ const api = new PhotosAPIService(apiService);
56
+ const cache = new SharesCache(telemetry.getLogger('shares-cache'), driveEntitiesCache);
57
+ const cryptoCache = new SharesCryptoCache(telemetry.getLogger('shares-cache'), driveCryptoCache);
58
+ const cryptoService = new SharesCryptoService(telemetry, crypto, account);
59
+
60
+ return new PhotoSharesManager(
61
+ telemetry.getLogger('photos-shares'),
62
+ api,
63
+ cache,
64
+ cryptoCache,
65
+ cryptoService,
66
+ sharesService,
67
+ );
68
+ }
@@ -1,5 +1,27 @@
1
- import { MissingNode } from '../../interface';
1
+ import { PrivateKey } from '../../crypto';
2
+ import { MissingNode, MetricVolumeType } from '../../interface';
2
3
  import { DecryptedNode } from '../nodes';
4
+ import { EncryptedShare } from '../shares';
5
+
6
+ export interface SharesService {
7
+ getOwnVolumeIDs(): Promise<{ volumeId: string; rootNodeId: string }>;
8
+ loadEncryptedShare(shareId: string): Promise<EncryptedShare>;
9
+ getSharePrivateKey(shareId: string): Promise<PrivateKey>;
10
+ getMyFilesShareMemberEmailKey(): Promise<{
11
+ email: string;
12
+ addressId: string;
13
+ addressKey: PrivateKey;
14
+ addressKeyId: string;
15
+ }>;
16
+ getContextShareMemberEmailKey(shareId: string): Promise<{
17
+ email: string;
18
+ addressId: string;
19
+ addressKey: PrivateKey;
20
+ addressKeyId: string;
21
+ }>;
22
+ isOwnVolume(volumeId: string): Promise<boolean>;
23
+ getVolumeMetricContext(volumeId: string): Promise<MetricVolumeType>;
24
+ }
3
25
 
4
26
  export interface NodesService {
5
27
  getNode(nodeUid: string): Promise<DecryptedNode>;