@protontech/drive-sdk 0.9.9 → 0.10.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 (165) hide show
  1. package/dist/crypto/driveCrypto.d.ts +15 -15
  2. package/dist/crypto/driveCrypto.js.map +1 -1
  3. package/dist/crypto/hmac.d.ts +3 -3
  4. package/dist/crypto/hmac.js.map +1 -1
  5. package/dist/crypto/interface.d.ts +45 -25
  6. package/dist/crypto/interface.js.map +1 -1
  7. package/dist/crypto/openPGPCrypto.d.ts +37 -37
  8. package/dist/crypto/openPGPCrypto.js.map +1 -1
  9. package/dist/crypto/utils.d.ts +1 -1
  10. package/dist/interface/index.d.ts +3 -3
  11. package/dist/interface/index.js.map +1 -1
  12. package/dist/interface/nodes.d.ts +8 -0
  13. package/dist/interface/photos.d.ts +18 -1
  14. package/dist/interface/sharing.d.ts +2 -0
  15. package/dist/interface/telemetry.d.ts +1 -0
  16. package/dist/interface/telemetry.js.map +1 -1
  17. package/dist/interface/thumbnail.d.ts +2 -2
  18. package/dist/internal/apiService/apiService.js +25 -12
  19. package/dist/internal/apiService/apiService.js.map +1 -1
  20. package/dist/internal/apiService/apiService.test.js +33 -5
  21. package/dist/internal/apiService/apiService.test.js.map +1 -1
  22. package/dist/internal/apiService/driveTypes.d.ts +2942 -3187
  23. package/dist/internal/apiService/errors.test.js +17 -7
  24. package/dist/internal/apiService/errors.test.js.map +1 -1
  25. package/dist/internal/devices/manager.d.ts +1 -0
  26. package/dist/internal/devices/manager.js +11 -0
  27. package/dist/internal/devices/manager.js.map +1 -1
  28. package/dist/internal/download/apiService.d.ts +1 -1
  29. package/dist/internal/download/cryptoService.d.ts +4 -4
  30. package/dist/internal/download/cryptoService.js.map +1 -1
  31. package/dist/internal/download/fileDownloader.js.map +1 -1
  32. package/dist/internal/download/fileDownloader.test.js.map +1 -1
  33. package/dist/internal/download/thumbnailDownloader.js.map +1 -1
  34. package/dist/internal/nodes/cryptoService.d.ts +4 -4
  35. package/dist/internal/nodes/cryptoService.js +5 -3
  36. package/dist/internal/nodes/cryptoService.js.map +1 -1
  37. package/dist/internal/nodes/cryptoService.test.js.map +1 -1
  38. package/dist/internal/nodes/interface.d.ts +1 -1
  39. package/dist/internal/nodes/nodesManagement.js +0 -1
  40. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  41. package/dist/internal/photos/addToAlbum.d.ts +46 -0
  42. package/dist/internal/photos/addToAlbum.js +257 -0
  43. package/dist/internal/photos/addToAlbum.js.map +1 -0
  44. package/dist/internal/photos/addToAlbum.test.d.ts +1 -0
  45. package/dist/internal/photos/addToAlbum.test.js +409 -0
  46. package/dist/internal/photos/addToAlbum.test.js.map +1 -0
  47. package/dist/internal/photos/albums.d.ts +5 -3
  48. package/dist/internal/photos/albums.js +13 -1
  49. package/dist/internal/photos/albums.js.map +1 -1
  50. package/dist/internal/photos/albums.test.js +2 -1
  51. package/dist/internal/photos/albums.test.js.map +1 -1
  52. package/dist/internal/photos/albumsCrypto.d.ts +20 -3
  53. package/dist/internal/photos/albumsCrypto.js +27 -0
  54. package/dist/internal/photos/albumsCrypto.js.map +1 -1
  55. package/dist/internal/photos/apiService.d.ts +19 -3
  56. package/dist/internal/photos/apiService.js +104 -5
  57. package/dist/internal/photos/apiService.js.map +1 -1
  58. package/dist/internal/photos/apiService.test.d.ts +1 -0
  59. package/dist/internal/photos/apiService.test.js +199 -0
  60. package/dist/internal/photos/apiService.test.js.map +1 -0
  61. package/dist/internal/photos/errors.d.ts +4 -0
  62. package/dist/internal/photos/errors.js +17 -0
  63. package/dist/internal/photos/errors.js.map +1 -0
  64. package/dist/internal/photos/index.js +1 -1
  65. package/dist/internal/photos/index.js.map +1 -1
  66. package/dist/internal/photos/interface.d.ts +15 -1
  67. package/dist/internal/photos/interface.js.map +1 -1
  68. package/dist/internal/photos/nodes.js +32 -2
  69. package/dist/internal/photos/nodes.js.map +1 -1
  70. package/dist/internal/photos/nodes.test.js +25 -5
  71. package/dist/internal/photos/nodes.test.js.map +1 -1
  72. package/dist/internal/photos/upload.d.ts +2 -2
  73. package/dist/internal/photos/upload.js.map +1 -1
  74. package/dist/internal/shares/apiService.js +1 -0
  75. package/dist/internal/shares/apiService.js.map +1 -1
  76. package/dist/internal/shares/interface.d.ts +1 -0
  77. package/dist/internal/sharing/apiService.d.ts +8 -1
  78. package/dist/internal/sharing/apiService.js +23 -1
  79. package/dist/internal/sharing/apiService.js.map +1 -1
  80. package/dist/internal/sharing/cryptoService.js +8 -4
  81. package/dist/internal/sharing/cryptoService.js.map +1 -1
  82. package/dist/internal/sharing/sharingManagement.d.ts +1 -0
  83. package/dist/internal/sharing/sharingManagement.js +15 -2
  84. package/dist/internal/sharing/sharingManagement.js.map +1 -1
  85. package/dist/internal/sharing/sharingManagement.test.js +30 -5
  86. package/dist/internal/sharing/sharingManagement.test.js.map +1 -1
  87. package/dist/internal/sharingPublic/nodes.d.ts +2 -2
  88. package/dist/internal/sharingPublic/nodes.js.map +1 -1
  89. package/dist/internal/upload/apiService.d.ts +5 -5
  90. package/dist/internal/upload/apiService.js.map +1 -1
  91. package/dist/internal/upload/blockVerifier.d.ts +2 -2
  92. package/dist/internal/upload/blockVerifier.js.map +1 -1
  93. package/dist/internal/upload/chunkStreamReader.d.ts +2 -2
  94. package/dist/internal/upload/chunkStreamReader.js.map +1 -1
  95. package/dist/internal/upload/chunkStreamReader.test.js.map +1 -1
  96. package/dist/internal/upload/cryptoService.d.ts +7 -7
  97. package/dist/internal/upload/cryptoService.js.map +1 -1
  98. package/dist/internal/upload/interface.d.ts +6 -6
  99. package/dist/internal/upload/manager.d.ts +1 -1
  100. package/dist/internal/upload/manager.js.map +1 -1
  101. package/dist/internal/upload/streamUploader.d.ts +1 -1
  102. package/dist/internal/utils.d.ts +1 -1
  103. package/dist/protonDriveClient.d.ts +8 -0
  104. package/dist/protonDriveClient.js +11 -0
  105. package/dist/protonDriveClient.js.map +1 -1
  106. package/dist/protonDrivePhotosClient.d.ts +17 -2
  107. package/dist/protonDrivePhotosClient.js +21 -1
  108. package/dist/protonDrivePhotosClient.js.map +1 -1
  109. package/dist/transformers.js +2 -0
  110. package/dist/transformers.js.map +1 -1
  111. package/package.json +4 -4
  112. package/src/crypto/driveCrypto.ts +15 -15
  113. package/src/crypto/hmac.ts +4 -4
  114. package/src/crypto/interface.ts +58 -27
  115. package/src/crypto/openPGPCrypto.ts +26 -26
  116. package/src/interface/index.ts +10 -2
  117. package/src/interface/nodes.ts +1 -0
  118. package/src/interface/photos.ts +19 -1
  119. package/src/interface/sharing.ts +2 -0
  120. package/src/interface/telemetry.ts +1 -0
  121. package/src/interface/thumbnail.ts +2 -2
  122. package/src/internal/apiService/apiService.test.ts +38 -6
  123. package/src/internal/apiService/apiService.ts +33 -12
  124. package/src/internal/apiService/driveTypes.ts +2942 -3187
  125. package/src/internal/devices/manager.ts +14 -0
  126. package/src/internal/download/apiService.ts +1 -1
  127. package/src/internal/download/cryptoService.ts +4 -4
  128. package/src/internal/download/fileDownloader.test.ts +4 -4
  129. package/src/internal/download/fileDownloader.ts +6 -6
  130. package/src/internal/download/thumbnailDownloader.ts +4 -4
  131. package/src/internal/nodes/cryptoService.test.ts +2 -2
  132. package/src/internal/nodes/cryptoService.ts +11 -8
  133. package/src/internal/nodes/interface.ts +1 -1
  134. package/src/internal/nodes/nodesManagement.ts +0 -1
  135. package/src/internal/photos/addToAlbum.test.ts +515 -0
  136. package/src/internal/photos/addToAlbum.ts +341 -0
  137. package/src/internal/photos/albums.test.ts +20 -23
  138. package/src/internal/photos/albums.ts +31 -2
  139. package/src/internal/photos/albumsCrypto.ts +54 -3
  140. package/src/internal/photos/apiService.test.ts +233 -0
  141. package/src/internal/photos/apiService.ts +172 -27
  142. package/src/internal/photos/errors.ts +11 -0
  143. package/src/internal/photos/index.ts +1 -1
  144. package/src/internal/photos/interface.ts +18 -3
  145. package/src/internal/photos/nodes.test.ts +27 -6
  146. package/src/internal/photos/nodes.ts +34 -2
  147. package/src/internal/photos/upload.ts +2 -2
  148. package/src/internal/shares/apiService.ts +1 -0
  149. package/src/internal/shares/interface.ts +1 -0
  150. package/src/internal/sharing/apiService.ts +49 -5
  151. package/src/internal/sharing/cryptoService.ts +10 -4
  152. package/src/internal/sharing/sharingManagement.test.ts +33 -5
  153. package/src/internal/sharing/sharingManagement.ts +28 -6
  154. package/src/internal/sharingPublic/nodes.ts +1 -1
  155. package/src/internal/upload/apiService.ts +5 -5
  156. package/src/internal/upload/blockVerifier.ts +3 -3
  157. package/src/internal/upload/chunkStreamReader.test.ts +7 -7
  158. package/src/internal/upload/chunkStreamReader.ts +3 -3
  159. package/src/internal/upload/cryptoService.ts +9 -9
  160. package/src/internal/upload/interface.ts +6 -6
  161. package/src/internal/upload/manager.ts +2 -2
  162. package/src/internal/upload/streamUploader.ts +1 -1
  163. package/src/protonDriveClient.ts +15 -3
  164. package/src/protonDrivePhotosClient.ts +39 -15
  165. package/src/transformers.ts +2 -0
@@ -0,0 +1,341 @@
1
+ import { c } from 'ttag';
2
+
3
+ import { ValidationError } from '../../errors';
4
+ import { Logger, NodeResultWithError } from '../../interface';
5
+ import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface';
6
+ import { splitNodeUid } from '../uids';
7
+ import { AlbumsCryptoService } from './albumsCrypto';
8
+ import { PhotosAPIService } from './apiService';
9
+ import { MissingRelatedPhotosError } from './errors';
10
+ import { AddToAlbumEncryptedPhotoPayload, DecryptedPhotoNode } from './interface';
11
+ import { PhotosNodesAccess } from './nodes';
12
+
13
+ /**
14
+ * The number of photos that are loaded in parallel to prepare the payloads.
15
+ */
16
+ const BATCH_LOADING_SIZE = 20;
17
+
18
+ /**
19
+ * The maximum number of photos that can be added to an album in a single
20
+ * request. The size includes the photo itself and its related photos.
21
+ */
22
+ const ADD_PHOTOS_BATCH_SIZE = 10;
23
+
24
+ /**
25
+ * Item in the processing queue representing a photo to add to an album.
26
+ */
27
+ type PhotoQueueItem = {
28
+ photoNodeUid: string;
29
+ /**
30
+ * When retrying after a MissingRelatedPhotosError, these contain the
31
+ * node UIDs reported as missing by the server that need to be included
32
+ * as additional related photos.
33
+ */
34
+ additionalRelatedPhotoNodeUids: string[];
35
+ };
36
+
37
+ /**
38
+ * Manages the process of adding photos to an album.
39
+ *
40
+ * Photos are split into two queues based on volume:
41
+ * - Same volume: added in batches via the add-multiple endpoint.
42
+ * - Different volume: copied individually via the copy endpoint.
43
+ *
44
+ * Both paths handle MissingRelatedPhotosError by re-queuing the failed
45
+ * photo with updated related photo UIDs for one retry attempt.
46
+ */
47
+ export class AddToAlbumProcess {
48
+ private readonly albumVolumeId: string;
49
+ private readonly retriedPhotoUids = new Set<string>();
50
+
51
+ constructor(
52
+ private readonly albumNodeUid: string,
53
+ private readonly albumKeys: DecryptedNodeKeys,
54
+ private readonly signingKeys: NodeSigningKeys,
55
+ private readonly apiService: PhotosAPIService,
56
+ private readonly cryptoService: AlbumsCryptoService,
57
+ private readonly nodesService: PhotosNodesAccess,
58
+ private readonly logger: Logger,
59
+ private readonly signal?: AbortSignal,
60
+ ) {
61
+ this.albumVolumeId = splitNodeUid(albumNodeUid).volumeId;
62
+ }
63
+
64
+ async *execute(photoNodeUids: string[]): AsyncGenerator<NodeResultWithError> {
65
+ const { sameVolumeQueue, differentVolumeQueue } = splitByVolume(photoNodeUids, this.albumVolumeId);
66
+
67
+ yield* this.processSameVolumeQueue(sameVolumeQueue);
68
+ yield* this.processDifferentVolumeQueue(differentVolumeQueue);
69
+ }
70
+
71
+ private async *processSameVolumeQueue(queue: PhotoQueueItem[]): AsyncGenerator<NodeResultWithError> {
72
+ while (queue.length > 0) {
73
+ const items = queue.splice(0, BATCH_LOADING_SIZE);
74
+ const { payloads, errors } = await this.preparePhotoPayloads(items);
75
+
76
+ for (const [uid, error] of errors) {
77
+ yield { uid, ok: false, error };
78
+ }
79
+
80
+ for (const batch of createBatches(payloads)) {
81
+ for await (const result of this.apiService.addPhotosToAlbum(this.albumNodeUid, batch, this.signal)) {
82
+ const retryItem = this.handleMissingRelatedPhotosError(result);
83
+ if (retryItem) {
84
+ queue.push(retryItem);
85
+ continue;
86
+ }
87
+
88
+ if (result.ok) {
89
+ await this.nodesService.notifyNodeChanged(result.uid);
90
+ }
91
+ yield result;
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ private async *processDifferentVolumeQueue(queue: PhotoQueueItem[]): AsyncGenerator<NodeResultWithError> {
98
+ while (queue.length > 0) {
99
+ const items = queue.splice(0, BATCH_LOADING_SIZE);
100
+ const { payloads, errors } = await this.preparePhotoPayloads(items);
101
+
102
+ for (const [uid, error] of errors) {
103
+ yield { uid, ok: false, error };
104
+ }
105
+
106
+ for (const payload of payloads) {
107
+ try {
108
+ const newPhotoNodeUid = await this.apiService.copyPhotoToAlbum(this.albumNodeUid, payload, this.signal);
109
+ await this.nodesService.notifyChildCreated(newPhotoNodeUid);
110
+ yield { uid: payload.nodeUid, ok: true };
111
+ } catch (error) {
112
+ if (error instanceof MissingRelatedPhotosError) {
113
+ const retryItem = this.createRetryQueueItem(payload.nodeUid, error);
114
+ if (retryItem) {
115
+ queue.push(retryItem);
116
+ continue;
117
+ }
118
+ }
119
+ yield {
120
+ uid: payload.nodeUid,
121
+ ok: false,
122
+ error: error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }),
123
+ };
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ private async preparePhotoPayloads(items: PhotoQueueItem[]): Promise<{
130
+ payloads: AddToAlbumEncryptedPhotoPayload[];
131
+ errors: Map<string, Error>;
132
+ }> {
133
+ const payloads: AddToAlbumEncryptedPhotoPayload[] = [];
134
+ const errors = new Map<string, Error>();
135
+
136
+ const additionalRelatedMap = new Map(
137
+ items.map((item) => [item.photoNodeUid, item.additionalRelatedPhotoNodeUids]),
138
+ );
139
+
140
+ const nodeUids = items.map((item) => item.photoNodeUid);
141
+ for await (const photoNode of this.nodesService.iterateNodes(nodeUids, this.signal)) {
142
+ if ('missingUid' in photoNode) {
143
+ errors.set(photoNode.missingUid, new ValidationError(c('Error').t`Photo not found`));
144
+ continue;
145
+ }
146
+
147
+ try {
148
+ const additionalRelated = additionalRelatedMap.get(photoNode.uid) || [];
149
+ const payload = await this.preparePhotoPayload(photoNode, additionalRelated);
150
+ payloads.push(payload);
151
+ } catch (error) {
152
+ errors.set(
153
+ photoNode.uid,
154
+ error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }),
155
+ );
156
+ }
157
+ }
158
+
159
+ return { payloads, errors };
160
+ }
161
+
162
+ private async preparePhotoPayload(
163
+ photoNode: DecryptedPhotoNode,
164
+ additionalRelatedPhotoNodeUids: string[],
165
+ ): Promise<AddToAlbumEncryptedPhotoPayload> {
166
+ const photoData = await this.encryptPhotoForAlbum(photoNode);
167
+
168
+ const relatedNodeUids = [...new Set([
169
+ ...(photoNode.photo?.relatedPhotoNodeUids || []),
170
+ ...additionalRelatedPhotoNodeUids,
171
+ ])];
172
+
173
+ const relatedPhotos =
174
+ relatedNodeUids.length > 0 ? await this.prepareRelatedPhotoPayloads(relatedNodeUids) : [];
175
+
176
+ return {
177
+ ...photoData,
178
+ relatedPhotos,
179
+ };
180
+ }
181
+
182
+ private async prepareRelatedPhotoPayloads(
183
+ nodeUids: string[],
184
+ ): Promise<Omit<AddToAlbumEncryptedPhotoPayload, 'relatedPhotos'>[]> {
185
+ const payloads: Omit<AddToAlbumEncryptedPhotoPayload, 'relatedPhotos'>[] = [];
186
+
187
+ for await (const photoNode of this.nodesService.iterateNodes(nodeUids, this.signal)) {
188
+ // Missing related photos means that the related photo was deleted
189
+ // since the loading of the metadata. It can happen and should be
190
+ // ignored. The backend controls all the related photos are part
191
+ // of the request, thus the request will fail and be retried if
192
+ // there is any other race condition.
193
+ if ('missingUid' in photoNode) {
194
+ continue;
195
+ }
196
+ const payload = await this.encryptPhotoForAlbum(photoNode);
197
+ payloads.push(payload);
198
+ }
199
+
200
+ return payloads;
201
+ }
202
+
203
+ private async encryptPhotoForAlbum(
204
+ photoNode: DecryptedPhotoNode,
205
+ ): Promise<AddToAlbumEncryptedPhotoPayload> {
206
+ const nodeKeys = await this.nodesService.getNodePrivateAndSessionKeys(photoNode.uid);
207
+
208
+ const contentSha1 = photoNode.activeRevision?.ok
209
+ ? photoNode.activeRevision.value.claimedDigests?.sha1
210
+ : undefined;
211
+
212
+ if (!contentSha1) {
213
+ throw new Error('Cannot add photo to album without a content hash');
214
+ }
215
+
216
+ const encryptedCrypto = await this.cryptoService.encryptPhotoForAlbum(
217
+ photoNode.name,
218
+ contentSha1,
219
+ nodeKeys,
220
+ { key: this.albumKeys.key, hashKey: this.albumKeys.hashKey! },
221
+ this.signingKeys,
222
+ );
223
+
224
+ // Node could be uploaded or renamed by anonymous user and thus have
225
+ // missing signatures that must be added to the request.
226
+ // Node passphrase and signature email must be passed if and only if
227
+ // the signatures are missing (key author is null).
228
+ const anonymousKey = photoNode.keyAuthor.ok && photoNode.keyAuthor.value === null;
229
+ const keySignatureProperties = !anonymousKey
230
+ ? {}
231
+ : {
232
+ signatureEmail: encryptedCrypto.signatureEmail,
233
+ nodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature,
234
+ };
235
+
236
+ return {
237
+ nodeUid: photoNode.uid,
238
+ contentHash: encryptedCrypto.contentHash,
239
+ nameHash: encryptedCrypto.hash,
240
+ encryptedName: encryptedCrypto.encryptedName,
241
+ nameSignatureEmail: encryptedCrypto.nameSignatureEmail,
242
+ nodePassphrase: encryptedCrypto.armoredNodePassphrase,
243
+ ...keySignatureProperties,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * If the result indicates a MissingRelatedPhotosError that hasn't
249
+ * been retried, returns a retry queue item. Otherwise returns undefined.
250
+ */
251
+ private handleMissingRelatedPhotosError(result: NodeResultWithError): PhotoQueueItem | undefined {
252
+ if (!result.ok && result.error instanceof MissingRelatedPhotosError) {
253
+ return this.createRetryQueueItem(result.uid, result.error);
254
+ }
255
+ return undefined;
256
+ }
257
+
258
+ /**
259
+ * Creates a retry queue item with the missing related photo UIDs.
260
+ * Returns undefined if the photo has already been retried, preventing
261
+ * infinite retry loops.
262
+ */
263
+ private createRetryQueueItem(
264
+ photoNodeUid: string,
265
+ error: MissingRelatedPhotosError,
266
+ ): PhotoQueueItem | undefined {
267
+ if (this.retriedPhotoUids.has(photoNodeUid)) {
268
+ this.logger.warn(`Missing related photos for ${photoNodeUid}, already retried`);
269
+ return undefined;
270
+ }
271
+
272
+ this.retriedPhotoUids.add(photoNodeUid);
273
+ this.logger.info(
274
+ `Missing related photos for ${photoNodeUid}, re-queuing: ${error.missingNodeUids.join(', ')}`,
275
+ );
276
+
277
+ return {
278
+ photoNodeUid,
279
+ additionalRelatedPhotoNodeUids: error.missingNodeUids,
280
+ };
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Splits photo UIDs into same-volume and different-volume queues
286
+ * based on the album's volume ID.
287
+ */
288
+ function splitByVolume(
289
+ photoNodeUids: string[],
290
+ albumVolumeId: string,
291
+ ): {
292
+ sameVolumeQueue: PhotoQueueItem[];
293
+ differentVolumeQueue: PhotoQueueItem[];
294
+ } {
295
+ const sameVolumeQueue: PhotoQueueItem[] = [];
296
+ const differentVolumeQueue: PhotoQueueItem[] = [];
297
+
298
+ for (const photoNodeUid of photoNodeUids) {
299
+ const { volumeId } = splitNodeUid(photoNodeUid);
300
+ const item: PhotoQueueItem = {
301
+ photoNodeUid,
302
+ additionalRelatedPhotoNodeUids: [],
303
+ };
304
+
305
+ if (volumeId === albumVolumeId) {
306
+ sameVolumeQueue.push(item);
307
+ } else {
308
+ differentVolumeQueue.push(item);
309
+ }
310
+ }
311
+
312
+ return { sameVolumeQueue, differentVolumeQueue };
313
+ }
314
+
315
+ /**
316
+ * Groups payloads into batches respecting the API limit.
317
+ * Each payload's size counts itself plus its related photos.
318
+ */
319
+ function* createBatches(
320
+ payloads: AddToAlbumEncryptedPhotoPayload[],
321
+ ): Generator<AddToAlbumEncryptedPhotoPayload[]> {
322
+ let batch: AddToAlbumEncryptedPhotoPayload[] = [];
323
+ let batchSize = 0;
324
+
325
+ for (const payload of payloads) {
326
+ const payloadSize = 1 + (payload.relatedPhotos?.length || 0);
327
+
328
+ if (batch.length > 0 && batchSize + payloadSize > ADD_PHOTOS_BATCH_SIZE) {
329
+ yield batch;
330
+ batch = [];
331
+ batchSize = 0;
332
+ }
333
+
334
+ batch.push(payload);
335
+ batchSize += payloadSize;
336
+ }
337
+
338
+ if (batch.length > 0) {
339
+ yield batch;
340
+ }
341
+ }
@@ -1,5 +1,6 @@
1
- import { NodeType, MemberRole } from '../../interface';
1
+ import { NodeType } from '../../interface';
2
2
  import { ValidationError } from '../../errors';
3
+ import { getMockTelemetry } from '../../tests/telemetry';
3
4
  import { Albums } from './albums';
4
5
  import { AlbumsCryptoService } from './albumsCrypto';
5
6
  import { PhotosAPIService } from './apiService';
@@ -96,7 +97,7 @@ describe('Albums', () => {
96
97
  notifyChildCreated: jest.fn(),
97
98
  };
98
99
 
99
- albums = new Albums(apiService, cryptoService, photoShares, nodesService);
100
+ albums = new Albums(getMockTelemetry(), apiService, cryptoService, photoShares, nodesService);
100
101
  });
101
102
 
102
103
  describe('createAlbum', () => {
@@ -172,16 +173,12 @@ describe('Albums', () => {
172
173
  { type: 'userAddress', email: 'user@example.com', addressId: 'addressId', key: 'addressKey' },
173
174
  'new album name',
174
175
  );
175
- expect(apiService.updateAlbum).toHaveBeenCalledWith(
176
- 'albumNodeUid',
177
- undefined,
178
- {
179
- encryptedName: 'newArmoredAlbumName',
180
- hash: 'newHash',
181
- originalHash: 'albumHash',
182
- nameSignatureEmail: 'newSignatureEmail',
183
- },
184
- );
176
+ expect(apiService.updateAlbum).toHaveBeenCalledWith('albumNodeUid', undefined, {
177
+ encryptedName: 'newArmoredAlbumName',
178
+ hash: 'newHash',
179
+ originalHash: 'albumHash',
180
+ nameSignatureEmail: 'newSignatureEmail',
181
+ });
185
182
  expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('albumNodeUid');
186
183
  });
187
184
 
@@ -207,16 +204,12 @@ describe('Albums', () => {
207
204
  nameAuthor: { ok: true, value: 'newSignatureEmail' },
208
205
  hash: 'newHash',
209
206
  });
210
- expect(apiService.updateAlbum).toHaveBeenCalledWith(
211
- 'albumNodeUid',
212
- 'photoNodeUid',
213
- {
214
- encryptedName: 'newArmoredAlbumName',
215
- hash: 'newHash',
216
- originalHash: 'albumHash',
217
- nameSignatureEmail: 'newSignatureEmail',
218
- },
219
- );
207
+ expect(apiService.updateAlbum).toHaveBeenCalledWith('albumNodeUid', 'photoNodeUid', {
208
+ encryptedName: 'newArmoredAlbumName',
209
+ hash: 'newHash',
210
+ originalHash: 'albumHash',
211
+ nameSignatureEmail: 'newSignatureEmail',
212
+ });
220
213
  });
221
214
 
222
215
  it('throws validation error for invalid album name', async () => {
@@ -258,7 +251,11 @@ describe('Albums', () => {
258
251
  { uid: 'photo2', ok: false, error: 'Some error' },
259
252
  { uid: 'photo3', ok: true },
260
253
  ]);
261
- expect(apiService.removePhotosFromAlbum).toHaveBeenCalledWith('albumNodeUid', ['photo1', 'photo2', 'photo3'], undefined);
254
+ expect(apiService.removePhotosFromAlbum).toHaveBeenCalledWith(
255
+ 'albumNodeUid',
256
+ ['photo1', 'photo2', 'photo3'],
257
+ undefined,
258
+ );
262
259
  expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(2);
263
260
  expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('photo1');
264
261
  expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('photo3');
@@ -1,9 +1,10 @@
1
- import { MemberRole, NodeResult, NodeType, resultOk } from '../../interface';
1
+ import { Logger, MemberRole, NodeResultWithError, NodeType, ProtonDriveTelemetry, resultOk } from '../../interface';
2
2
  import { BatchLoading } from '../batchLoading';
3
3
  import { DecryptedNode } from '../nodes';
4
4
  import { ALBUM_MEDIA_TYPE } from '../nodes/mediaTypes';
5
5
  import { validateNodeName } from '../nodes/validations';
6
6
  import { splitNodeUid } from '../uids';
7
+ import { AddToAlbumProcess } from './addToAlbum';
7
8
  import { AlbumsCryptoService } from './albumsCrypto';
8
9
  import { PhotosAPIService } from './apiService';
9
10
  import { AlbumItem, DecryptedPhotoNode } from './interface';
@@ -16,12 +17,16 @@ const BATCH_LOADING_SIZE = 10;
16
17
  * Provides access and high-level actions for managing albums.
17
18
  */
18
19
  export class Albums {
20
+ private logger: Logger;
21
+
19
22
  constructor(
23
+ telemetry: ProtonDriveTelemetry,
20
24
  private apiService: PhotosAPIService,
21
25
  private cryptoService: AlbumsCryptoService,
22
26
  private photoShares: PhotoSharesManager,
23
27
  private nodesService: PhotosNodesAccess,
24
28
  ) {
29
+ this.logger = telemetry.getLogger('albums');
25
30
  this.apiService = apiService;
26
31
  this.cryptoService = cryptoService;
27
32
  this.photoShares = photoShares;
@@ -156,11 +161,35 @@ export class Albums {
156
161
  await this.nodesService.notifyNodeDeleted(nodeUid);
157
162
  }
158
163
 
164
+ async *addPhotos(
165
+ albumNodeUid: string,
166
+ photoNodeUids: string[],
167
+ signal?: AbortSignal,
168
+ ): AsyncGenerator<NodeResultWithError> {
169
+ const albumKeys = await this.nodesService.getNodeKeys(albumNodeUid);
170
+ if (!albumKeys.hashKey) {
171
+ throw new Error('Cannot add photos to album: album hash key not available');
172
+ }
173
+ const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid: albumNodeUid });
174
+
175
+ const process = new AddToAlbumProcess(
176
+ albumNodeUid,
177
+ albumKeys,
178
+ signingKeys,
179
+ this.apiService,
180
+ this.cryptoService,
181
+ this.nodesService,
182
+ this.logger,
183
+ signal,
184
+ );
185
+ yield * process.execute(photoNodeUids);
186
+ }
187
+
159
188
  async *removePhotos(
160
189
  albumNodeUid: string,
161
190
  photoNodeUids: string[],
162
191
  signal?: AbortSignal,
163
- ): AsyncGenerator<NodeResult> {
192
+ ): AsyncGenerator<NodeResultWithError> {
164
193
  for await (const result of this.apiService.removePhotosFromAlbum(albumNodeUid, photoNodeUids, signal)) {
165
194
  if (result.ok) {
166
195
  await this.nodesService.notifyNodeChanged(result.uid);
@@ -1,4 +1,7 @@
1
- import { DriveCrypto, PrivateKey } from '../../crypto';
1
+ import { c } from 'ttag';
2
+ import { DriveCrypto, PrivateKey, SessionKey } from '../../crypto';
3
+ import { ValidationError } from '../../errors';
4
+ import { InvalidNameError, Result } from '../../interface';
2
5
  import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface';
3
6
 
4
7
  /**
@@ -13,7 +16,7 @@ export class AlbumsCryptoService {
13
16
  }
14
17
 
15
18
  async createAlbum(
16
- parentKeys: { key: PrivateKey; hashKey: Uint8Array },
19
+ parentKeys: { key: PrivateKey; hashKey: Uint8Array<ArrayBuffer> },
17
20
  signingKeys: NodeSigningKeys,
18
21
  name: string,
19
22
  ): Promise<{
@@ -62,7 +65,7 @@ export class AlbumsCryptoService {
62
65
  }
63
66
 
64
67
  async renameAlbum(
65
- parentKeys: { key: PrivateKey; hashKey?: Uint8Array },
68
+ parentKeys: { key: PrivateKey; hashKey?: Uint8Array<ArrayBuffer> },
66
69
  encryptedName: string,
67
70
  signingKeys: NodeSigningKeys,
68
71
  newName: string,
@@ -97,4 +100,52 @@ export class AlbumsCryptoService {
97
100
  hash,
98
101
  };
99
102
  }
103
+
104
+ async encryptPhotoForAlbum(
105
+ nodeName: Result<string, Error | InvalidNameError>,
106
+ sha1: string,
107
+ nodeKeys: { passphrase: string; passphraseSessionKey: SessionKey; nameSessionKey: SessionKey },
108
+ albumKeys: { key: PrivateKey; hashKey: Uint8Array<ArrayBuffer> },
109
+ signingKeys: NodeSigningKeys,
110
+ ): Promise<{
111
+ encryptedName: string;
112
+ hash: string;
113
+ contentHash: string;
114
+ armoredNodePassphrase: string;
115
+ armoredNodePassphraseSignature: string;
116
+ signatureEmail: string;
117
+ nameSignatureEmail: string;
118
+ }> {
119
+ if (!nodeName.ok) {
120
+ throw new ValidationError(c('Error').t`Cannot add photo to album without a valid name`);
121
+ }
122
+ if (signingKeys.type !== 'userAddress') {
123
+ throw new Error('Adding photos to album by anonymous user is not supported');
124
+ }
125
+ const email = signingKeys.email;
126
+ const signingKey = signingKeys.key;
127
+
128
+ const [{ armoredNodeName }, hash, contentHash, { armoredPassphrase, armoredPassphraseSignature }] =
129
+ await Promise.all([
130
+ this.driveCrypto.encryptNodeName(nodeName.value, nodeKeys.nameSessionKey, albumKeys.key, signingKey),
131
+ this.driveCrypto.generateLookupHash(nodeName.value, albumKeys.hashKey),
132
+ this.driveCrypto.generateLookupHash(sha1, albumKeys.hashKey),
133
+ this.driveCrypto.encryptPassphrase(
134
+ nodeKeys.passphrase,
135
+ nodeKeys.passphraseSessionKey,
136
+ [albumKeys.key],
137
+ signingKey,
138
+ ),
139
+ ]);
140
+
141
+ return {
142
+ encryptedName: armoredNodeName,
143
+ hash,
144
+ contentHash,
145
+ armoredNodePassphrase: armoredPassphrase,
146
+ armoredNodePassphraseSignature: armoredPassphraseSignature,
147
+ signatureEmail: email,
148
+ nameSignatureEmail: email,
149
+ };
150
+ }
100
151
  }