@protontech/drive-sdk 0.9.8 → 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 (174) 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 +7 -2
  48. package/dist/internal/photos/albums.js +24 -1
  49. package/dist/internal/photos/albums.js.map +1 -1
  50. package/dist/internal/photos/albums.test.js +26 -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 +20 -0
  56. package/dist/internal/photos/apiService.js +142 -0
  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.d.ts +1 -1
  65. package/dist/internal/photos/index.js +1 -1
  66. package/dist/internal/photos/index.js.map +1 -1
  67. package/dist/internal/photos/interface.d.ts +36 -1
  68. package/dist/internal/photos/interface.js +14 -0
  69. package/dist/internal/photos/interface.js.map +1 -1
  70. package/dist/internal/photos/nodes.js +32 -2
  71. package/dist/internal/photos/nodes.js.map +1 -1
  72. package/dist/internal/photos/nodes.test.js +25 -5
  73. package/dist/internal/photos/nodes.test.js.map +1 -1
  74. package/dist/internal/photos/timeline.d.ts +2 -5
  75. package/dist/internal/photos/timeline.js.map +1 -1
  76. package/dist/internal/photos/upload.d.ts +2 -2
  77. package/dist/internal/photos/upload.js.map +1 -1
  78. package/dist/internal/shares/apiService.js +1 -0
  79. package/dist/internal/shares/apiService.js.map +1 -1
  80. package/dist/internal/shares/interface.d.ts +1 -0
  81. package/dist/internal/sharing/apiService.d.ts +8 -1
  82. package/dist/internal/sharing/apiService.js +23 -1
  83. package/dist/internal/sharing/apiService.js.map +1 -1
  84. package/dist/internal/sharing/cryptoService.js +8 -4
  85. package/dist/internal/sharing/cryptoService.js.map +1 -1
  86. package/dist/internal/sharing/sharingManagement.d.ts +1 -0
  87. package/dist/internal/sharing/sharingManagement.js +15 -2
  88. package/dist/internal/sharing/sharingManagement.js.map +1 -1
  89. package/dist/internal/sharing/sharingManagement.test.js +30 -5
  90. package/dist/internal/sharing/sharingManagement.test.js.map +1 -1
  91. package/dist/internal/sharingPublic/nodes.d.ts +2 -2
  92. package/dist/internal/sharingPublic/nodes.js.map +1 -1
  93. package/dist/internal/upload/apiService.d.ts +5 -5
  94. package/dist/internal/upload/apiService.js.map +1 -1
  95. package/dist/internal/upload/blockVerifier.d.ts +2 -2
  96. package/dist/internal/upload/blockVerifier.js.map +1 -1
  97. package/dist/internal/upload/chunkStreamReader.d.ts +2 -2
  98. package/dist/internal/upload/chunkStreamReader.js.map +1 -1
  99. package/dist/internal/upload/chunkStreamReader.test.js.map +1 -1
  100. package/dist/internal/upload/cryptoService.d.ts +7 -7
  101. package/dist/internal/upload/cryptoService.js.map +1 -1
  102. package/dist/internal/upload/interface.d.ts +6 -6
  103. package/dist/internal/upload/manager.d.ts +1 -1
  104. package/dist/internal/upload/manager.js.map +1 -1
  105. package/dist/internal/upload/streamUploader.d.ts +1 -1
  106. package/dist/internal/utils.d.ts +1 -1
  107. package/dist/protonDriveClient.d.ts +8 -0
  108. package/dist/protonDriveClient.js +11 -0
  109. package/dist/protonDriveClient.js.map +1 -1
  110. package/dist/protonDrivePhotosClient.d.ts +42 -7
  111. package/dist/protonDrivePhotosClient.js +50 -2
  112. package/dist/protonDrivePhotosClient.js.map +1 -1
  113. package/dist/protonDrivePublicLinkClient.d.ts +9 -0
  114. package/dist/protonDrivePublicLinkClient.js +12 -0
  115. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  116. package/dist/transformers.js +2 -0
  117. package/dist/transformers.js.map +1 -1
  118. package/package.json +4 -4
  119. package/src/crypto/driveCrypto.ts +15 -15
  120. package/src/crypto/hmac.ts +4 -4
  121. package/src/crypto/interface.ts +58 -27
  122. package/src/crypto/openPGPCrypto.ts +26 -26
  123. package/src/interface/index.ts +10 -2
  124. package/src/interface/nodes.ts +1 -0
  125. package/src/interface/photos.ts +19 -1
  126. package/src/interface/sharing.ts +2 -0
  127. package/src/interface/telemetry.ts +1 -0
  128. package/src/interface/thumbnail.ts +2 -2
  129. package/src/internal/apiService/apiService.test.ts +38 -6
  130. package/src/internal/apiService/apiService.ts +33 -12
  131. package/src/internal/apiService/driveTypes.ts +2942 -3187
  132. package/src/internal/devices/manager.ts +14 -0
  133. package/src/internal/download/apiService.ts +1 -1
  134. package/src/internal/download/cryptoService.ts +4 -4
  135. package/src/internal/download/fileDownloader.test.ts +4 -4
  136. package/src/internal/download/fileDownloader.ts +6 -6
  137. package/src/internal/download/thumbnailDownloader.ts +4 -4
  138. package/src/internal/nodes/cryptoService.test.ts +2 -2
  139. package/src/internal/nodes/cryptoService.ts +11 -8
  140. package/src/internal/nodes/interface.ts +1 -1
  141. package/src/internal/nodes/nodesManagement.ts +0 -1
  142. package/src/internal/photos/addToAlbum.test.ts +515 -0
  143. package/src/internal/photos/addToAlbum.ts +341 -0
  144. package/src/internal/photos/albums.test.ts +46 -22
  145. package/src/internal/photos/albums.ts +48 -2
  146. package/src/internal/photos/albumsCrypto.ts +54 -3
  147. package/src/internal/photos/apiService.test.ts +233 -0
  148. package/src/internal/photos/apiService.ts +234 -15
  149. package/src/internal/photos/errors.ts +11 -0
  150. package/src/internal/photos/index.ts +2 -2
  151. package/src/internal/photos/interface.ts +40 -1
  152. package/src/internal/photos/nodes.test.ts +27 -6
  153. package/src/internal/photos/nodes.ts +34 -2
  154. package/src/internal/photos/timeline.ts +2 -5
  155. package/src/internal/photos/upload.ts +2 -2
  156. package/src/internal/shares/apiService.ts +1 -0
  157. package/src/internal/shares/interface.ts +1 -0
  158. package/src/internal/sharing/apiService.ts +49 -5
  159. package/src/internal/sharing/cryptoService.ts +10 -4
  160. package/src/internal/sharing/sharingManagement.test.ts +33 -5
  161. package/src/internal/sharing/sharingManagement.ts +28 -6
  162. package/src/internal/sharingPublic/nodes.ts +1 -1
  163. package/src/internal/upload/apiService.ts +5 -5
  164. package/src/internal/upload/blockVerifier.ts +3 -3
  165. package/src/internal/upload/chunkStreamReader.test.ts +7 -7
  166. package/src/internal/upload/chunkStreamReader.ts +3 -3
  167. package/src/internal/upload/cryptoService.ts +9 -9
  168. package/src/internal/upload/interface.ts +6 -6
  169. package/src/internal/upload/manager.ts +2 -2
  170. package/src/internal/upload/streamUploader.ts +1 -1
  171. package/src/protonDriveClient.ts +15 -3
  172. package/src/protonDrivePhotosClient.ts +78 -22
  173. package/src/protonDrivePublicLinkClient.ts +13 -0
  174. 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';
@@ -37,6 +38,7 @@ describe('Albums', () => {
37
38
  createAlbum: jest.fn().mockResolvedValue('volumeId~newAlbumNodeId'),
38
39
  updateAlbum: jest.fn(),
39
40
  deleteAlbum: jest.fn(),
41
+ removePhotosFromAlbum: jest.fn(),
40
42
  };
41
43
 
42
44
  // @ts-expect-error No need to implement all methods for mocking
@@ -95,7 +97,7 @@ describe('Albums', () => {
95
97
  notifyChildCreated: jest.fn(),
96
98
  };
97
99
 
98
- albums = new Albums(apiService, cryptoService, photoShares, nodesService);
100
+ albums = new Albums(getMockTelemetry(), apiService, cryptoService, photoShares, nodesService);
99
101
  });
100
102
 
101
103
  describe('createAlbum', () => {
@@ -171,16 +173,12 @@ describe('Albums', () => {
171
173
  { type: 'userAddress', email: 'user@example.com', addressId: 'addressId', key: 'addressKey' },
172
174
  'new album name',
173
175
  );
174
- expect(apiService.updateAlbum).toHaveBeenCalledWith(
175
- 'albumNodeUid',
176
- undefined,
177
- {
178
- encryptedName: 'newArmoredAlbumName',
179
- hash: 'newHash',
180
- originalHash: 'albumHash',
181
- nameSignatureEmail: 'newSignatureEmail',
182
- },
183
- );
176
+ expect(apiService.updateAlbum).toHaveBeenCalledWith('albumNodeUid', undefined, {
177
+ encryptedName: 'newArmoredAlbumName',
178
+ hash: 'newHash',
179
+ originalHash: 'albumHash',
180
+ nameSignatureEmail: 'newSignatureEmail',
181
+ });
184
182
  expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('albumNodeUid');
185
183
  });
186
184
 
@@ -206,16 +204,12 @@ describe('Albums', () => {
206
204
  nameAuthor: { ok: true, value: 'newSignatureEmail' },
207
205
  hash: 'newHash',
208
206
  });
209
- expect(apiService.updateAlbum).toHaveBeenCalledWith(
210
- 'albumNodeUid',
211
- 'photoNodeUid',
212
- {
213
- encryptedName: 'newArmoredAlbumName',
214
- hash: 'newHash',
215
- originalHash: 'albumHash',
216
- nameSignatureEmail: 'newSignatureEmail',
217
- },
218
- );
207
+ expect(apiService.updateAlbum).toHaveBeenCalledWith('albumNodeUid', 'photoNodeUid', {
208
+ encryptedName: 'newArmoredAlbumName',
209
+ hash: 'newHash',
210
+ originalHash: 'albumHash',
211
+ nameSignatureEmail: 'newSignatureEmail',
212
+ });
219
213
  });
220
214
 
221
215
  it('throws validation error for invalid album name', async () => {
@@ -238,4 +232,34 @@ describe('Albums', () => {
238
232
  expect(nodesService.notifyNodeDeleted).toHaveBeenCalledWith('albumNodeUid');
239
233
  });
240
234
  });
235
+
236
+ describe('removePhotos', () => {
237
+ it('notifies nodes service only for successfully removed photos', async () => {
238
+ apiService.removePhotosFromAlbum = jest.fn().mockImplementation(async function* () {
239
+ yield { uid: 'photo1', ok: true };
240
+ yield { uid: 'photo2', ok: false, error: 'Some error' };
241
+ yield { uid: 'photo3', ok: true };
242
+ });
243
+
244
+ const results = [];
245
+ for await (const result of albums.removePhotos('albumNodeUid', ['photo1', 'photo2', 'photo3'])) {
246
+ results.push(result);
247
+ }
248
+
249
+ expect(results).toEqual([
250
+ { uid: 'photo1', ok: true },
251
+ { uid: 'photo2', ok: false, error: 'Some error' },
252
+ { uid: 'photo3', ok: true },
253
+ ]);
254
+ expect(apiService.removePhotosFromAlbum).toHaveBeenCalledWith(
255
+ 'albumNodeUid',
256
+ ['photo1', 'photo2', 'photo3'],
257
+ undefined,
258
+ );
259
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(2);
260
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('photo1');
261
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('photo3');
262
+ expect(nodesService.notifyNodeChanged).not.toHaveBeenCalledWith('photo2');
263
+ });
264
+ });
241
265
  });
@@ -1,12 +1,13 @@
1
- import { MemberRole, 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
- import { DecryptedPhotoNode } from './interface';
10
+ import { AlbumItem, DecryptedPhotoNode } from './interface';
10
11
  import { PhotosNodesAccess } from './nodes';
11
12
  import { PhotoSharesManager } from './shares';
12
13
 
@@ -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;
@@ -41,6 +46,10 @@ export class Albums {
41
46
  yield* batchLoading.loadRest();
42
47
  }
43
48
 
49
+ async *iterateAlbum(albumNodeUid: string, signal?: AbortSignal): AsyncGenerator<AlbumItem> {
50
+ yield* this.apiService.iterateAlbumChildren(albumNodeUid, signal);
51
+ }
52
+
44
53
  async createAlbum(name: string): Promise<DecryptedPhotoNode> {
45
54
  validateNodeName(name);
46
55
 
@@ -152,6 +161,43 @@ export class Albums {
152
161
  await this.nodesService.notifyNodeDeleted(nodeUid);
153
162
  }
154
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
+
188
+ async *removePhotos(
189
+ albumNodeUid: string,
190
+ photoNodeUids: string[],
191
+ signal?: AbortSignal,
192
+ ): AsyncGenerator<NodeResultWithError> {
193
+ for await (const result of this.apiService.removePhotosFromAlbum(albumNodeUid, photoNodeUids, signal)) {
194
+ if (result.ok) {
195
+ await this.nodesService.notifyNodeChanged(result.uid);
196
+ }
197
+ yield result;
198
+ }
199
+ }
200
+
155
201
  private async *iterateNodesAndIgnoreMissingOnes(
156
202
  nodeUids: string[],
157
203
  signal?: AbortSignal,
@@ -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
  }