@protontech/drive-sdk 0.10.0 → 0.11.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 (105) hide show
  1. package/dist/crypto/driveCrypto.d.ts +4 -1
  2. package/dist/crypto/driveCrypto.js +23 -1
  3. package/dist/crypto/driveCrypto.js.map +1 -1
  4. package/dist/crypto/driveCrypto.test.js +2 -1
  5. package/dist/crypto/driveCrypto.test.js.map +1 -1
  6. package/dist/diagnostic/telemetry.js +3 -0
  7. package/dist/diagnostic/telemetry.js.map +1 -1
  8. package/dist/interface/index.d.ts +1 -0
  9. package/dist/interface/index.js +3 -1
  10. package/dist/interface/index.js.map +1 -1
  11. package/dist/interface/photos.d.ts +13 -1
  12. package/dist/interface/photos.js +14 -0
  13. package/dist/interface/photos.js.map +1 -1
  14. package/dist/interface/telemetry.d.ts +12 -1
  15. package/dist/interface/telemetry.js.map +1 -1
  16. package/dist/internal/apiService/apiService.d.ts +1 -1
  17. package/dist/internal/apiService/apiService.js +2 -2
  18. package/dist/internal/apiService/apiService.js.map +1 -1
  19. package/dist/internal/nodes/apiService.js.map +1 -1
  20. package/dist/internal/nodes/nodesAccess.js +1 -1
  21. package/dist/internal/nodes/nodesAccess.js.map +1 -1
  22. package/dist/internal/photos/addToAlbum.d.ts +1 -5
  23. package/dist/internal/photos/addToAlbum.js +8 -87
  24. package/dist/internal/photos/addToAlbum.js.map +1 -1
  25. package/dist/internal/photos/{albums.d.ts → albumsManager.d.ts} +1 -1
  26. package/dist/internal/photos/{albums.js → albumsManager.js} +4 -4
  27. package/dist/internal/photos/albumsManager.js.map +1 -0
  28. package/dist/internal/photos/{albums.test.js → albumsManager.test.js} +3 -3
  29. package/dist/internal/photos/albumsManager.test.js.map +1 -0
  30. package/dist/internal/photos/apiService.d.ts +8 -4
  31. package/dist/internal/photos/apiService.js +38 -6
  32. package/dist/internal/photos/apiService.js.map +1 -1
  33. package/dist/internal/photos/index.d.ts +5 -3
  34. package/dist/internal/photos/index.js +5 -2
  35. package/dist/internal/photos/index.js.map +1 -1
  36. package/dist/internal/photos/interface.d.ts +3 -26
  37. package/dist/internal/photos/interface.js +0 -14
  38. package/dist/internal/photos/interface.js.map +1 -1
  39. package/dist/internal/photos/nodes.js +1 -1
  40. package/dist/internal/photos/nodes.js.map +1 -1
  41. package/dist/internal/photos/photosManager.d.ts +22 -0
  42. package/dist/internal/photos/photosManager.js +101 -0
  43. package/dist/internal/photos/photosManager.js.map +1 -0
  44. package/dist/internal/photos/photosManager.test.d.ts +1 -0
  45. package/dist/internal/photos/photosManager.test.js +222 -0
  46. package/dist/internal/photos/photosManager.test.js.map +1 -0
  47. package/dist/internal/photos/photosTransferPayloadBuilder.d.ts +57 -0
  48. package/dist/internal/photos/photosTransferPayloadBuilder.js +113 -0
  49. package/dist/internal/photos/photosTransferPayloadBuilder.js.map +1 -0
  50. package/dist/internal/photos/photosTransferPayloadBuilder.test.d.ts +1 -0
  51. package/dist/internal/photos/photosTransferPayloadBuilder.test.js +289 -0
  52. package/dist/internal/photos/photosTransferPayloadBuilder.test.js.map +1 -0
  53. package/dist/internal/photos/upload.js +1 -1
  54. package/dist/internal/photos/upload.js.map +1 -1
  55. package/dist/internal/upload/apiService.d.ts +0 -4
  56. package/dist/internal/upload/apiService.js +0 -4
  57. package/dist/internal/upload/apiService.js.map +1 -1
  58. package/dist/internal/upload/cryptoService.js +4 -4
  59. package/dist/internal/upload/cryptoService.js.map +1 -1
  60. package/dist/internal/upload/interface.d.ts +1 -1
  61. package/dist/internal/upload/streamUploader.d.ts +1 -1
  62. package/dist/internal/upload/streamUploader.js +12 -13
  63. package/dist/internal/upload/streamUploader.js.map +1 -1
  64. package/dist/internal/upload/streamUploader.test.js +28 -4
  65. package/dist/internal/upload/streamUploader.test.js.map +1 -1
  66. package/dist/protonDriveClient.js +1 -1
  67. package/dist/protonDriveClient.js.map +1 -1
  68. package/dist/protonDrivePhotosClient.d.ts +19 -2
  69. package/dist/protonDrivePhotosClient.js +21 -1
  70. package/dist/protonDrivePhotosClient.js.map +1 -1
  71. package/dist/protonDrivePublicLinkClient.js +1 -1
  72. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  73. package/package.json +1 -1
  74. package/src/crypto/driveCrypto.test.ts +2 -1
  75. package/src/crypto/driveCrypto.ts +36 -2
  76. package/src/diagnostic/telemetry.ts +3 -0
  77. package/src/interface/index.ts +1 -0
  78. package/src/interface/photos.ts +14 -1
  79. package/src/interface/telemetry.ts +14 -1
  80. package/src/internal/apiService/apiService.ts +6 -2
  81. package/src/internal/nodes/apiService.ts +2 -2
  82. package/src/internal/nodes/nodesAccess.ts +1 -1
  83. package/src/internal/photos/addToAlbum.ts +29 -136
  84. package/src/internal/photos/{albums.test.ts → albumsManager.test.ts} +3 -3
  85. package/src/internal/photos/{albums.ts → albumsManager.ts} +1 -1
  86. package/src/internal/photos/apiService.ts +73 -16
  87. package/src/internal/photos/index.ts +6 -3
  88. package/src/internal/photos/interface.ts +3 -28
  89. package/src/internal/photos/nodes.ts +1 -1
  90. package/src/internal/photos/photosManager.test.ts +266 -0
  91. package/src/internal/photos/photosManager.ts +144 -0
  92. package/src/internal/photos/photosTransferPayloadBuilder.test.ts +380 -0
  93. package/src/internal/photos/photosTransferPayloadBuilder.ts +203 -0
  94. package/src/internal/photos/upload.ts +6 -1
  95. package/src/internal/upload/apiService.ts +7 -9
  96. package/src/internal/upload/cryptoService.ts +4 -5
  97. package/src/internal/upload/interface.ts +1 -1
  98. package/src/internal/upload/streamUploader.test.ts +33 -4
  99. package/src/internal/upload/streamUploader.ts +13 -13
  100. package/src/protonDriveClient.ts +1 -1
  101. package/src/protonDrivePhotosClient.ts +34 -2
  102. package/src/protonDrivePublicLinkClient.ts +1 -1
  103. package/dist/internal/photos/albums.js.map +0 -1
  104. package/dist/internal/photos/albums.test.js.map +0 -1
  105. /package/dist/internal/photos/{albums.test.d.ts → albumsManager.test.d.ts} +0 -0
@@ -0,0 +1,380 @@
1
+ import { ValidationError } from '../../errors';
2
+ import { resultOk } from '../../interface';
3
+ import { AlbumsCryptoService } from './albumsCrypto';
4
+ import { DecryptedPhotoNode } from './interface';
5
+ import { PhotoTransferPayloadBuilder } from './photosTransferPayloadBuilder';
6
+ import { PhotosNodesAccess } from './nodes';
7
+
8
+ /**
9
+ * Helper to create a mock photo node with minimal required properties.
10
+ */
11
+ function createMockPhotoNode(
12
+ uid: string,
13
+ overrides: Partial<DecryptedPhotoNode> = {},
14
+ ): DecryptedPhotoNode {
15
+ return {
16
+ uid,
17
+ parentUid: 'volume1~parent',
18
+ hash: 'hash',
19
+ name: resultOk('photo.jpg'),
20
+ photo: {
21
+ captureTime: new Date(),
22
+ mainPhotoNodeUid: undefined,
23
+ relatedPhotoNodeUids: [],
24
+ tags: [],
25
+ albums: [],
26
+ },
27
+ activeRevision: {
28
+ ok: true,
29
+ value: {
30
+ uid: 'rev1',
31
+ state: 'active' as const,
32
+ creationTime: new Date(),
33
+ storageSize: 100,
34
+ signatureEmail: 'test@example.com',
35
+ claimedModificationTime: new Date(),
36
+ claimedSize: 100,
37
+ claimedDigests: { sha1: 'sha1hash' },
38
+ claimedBlockSizes: [100],
39
+ },
40
+ },
41
+ keyAuthor: { ok: true, value: 'test@example.com' },
42
+ ...overrides,
43
+ } as DecryptedPhotoNode;
44
+ }
45
+
46
+ describe('PhotoTransferPayloadBuilder', () => {
47
+ let cryptoService: jest.Mocked<AlbumsCryptoService>;
48
+ let nodesService: jest.Mocked<PhotosNodesAccess>;
49
+ let targetKeys: { key: unknown; hashKey: Uint8Array };
50
+ let signingKeys: { type: 'userAddress'; email: string; addressId: string; key: unknown };
51
+ let builder: PhotoTransferPayloadBuilder;
52
+
53
+ beforeEach(() => {
54
+ targetKeys = {
55
+ key: 'targetKey' as any,
56
+ hashKey: new Uint8Array([1, 2, 3]),
57
+ };
58
+
59
+ signingKeys = {
60
+ type: 'userAddress',
61
+ email: 'test@example.com',
62
+ addressId: 'addressId',
63
+ key: 'signingKey' as any,
64
+ };
65
+
66
+ // @ts-expect-error Mocking for testing purposes
67
+ cryptoService = {
68
+ encryptPhotoForAlbum: jest.fn(),
69
+ };
70
+
71
+ // @ts-expect-error Mocking for testing purposes
72
+ nodesService = {
73
+ iterateNodes: jest.fn(),
74
+ getNodePrivateAndSessionKeys: jest.fn(),
75
+ };
76
+
77
+ builder = new PhotoTransferPayloadBuilder(cryptoService, nodesService);
78
+ });
79
+
80
+ describe('preparePhotoPayloads', () => {
81
+ beforeEach(() => {
82
+ nodesService.iterateNodes.mockImplementation(async function* (uids: string[]) {
83
+ for (const uid of uids) {
84
+ if (uid === 'volume1~missing') {
85
+ yield { missingUid: uid };
86
+ continue;
87
+ }
88
+
89
+ const photoNode = createMockPhotoNode(uid);
90
+
91
+ // Handle uids in the form 'volumeId~mainPhoto-related:N' where N is the number of related photos
92
+ const relatedMatch = /^(.+)~(.+)-related:(\d+)$/.exec(uid);
93
+ if (relatedMatch) {
94
+ const [, volumeId, , countStr] = relatedMatch;
95
+ const count = parseInt(countStr, 10);
96
+ photoNode.photo!.relatedPhotoNodeUids = Array.from(
97
+ { length: count },
98
+ (_, idx) => `${volumeId}~related${idx + 1}`,
99
+ );
100
+ }
101
+
102
+ yield photoNode;
103
+ }
104
+ });
105
+
106
+ nodesService.getNodePrivateAndSessionKeys.mockResolvedValue({
107
+ key: 'nodeKey' as any,
108
+ nameSessionKey: 'sessionKey' as any,
109
+ passphrase: 'passphrase',
110
+ passphraseSessionKey: 'passphraseSessionKey' as any,
111
+ });
112
+
113
+ cryptoService.encryptPhotoForAlbum.mockResolvedValue({
114
+ contentHash: 'contentHash',
115
+ hash: 'nameHash',
116
+ encryptedName: 'encryptedName',
117
+ nameSignatureEmail: 'test@example.com',
118
+ armoredNodePassphrase: 'passphrase',
119
+ armoredNodePassphraseSignature: 'signature',
120
+ signatureEmail: 'test@example.com',
121
+ });
122
+ });
123
+
124
+ it('should return payloads and empty errors for a single photo without related photos', async () => {
125
+ const items = [{ photoNodeUid: 'volume1~photo1' }];
126
+
127
+ const result = await builder.preparePhotoPayloads(
128
+ items,
129
+ 'volume1~root',
130
+ targetKeys as any,
131
+ signingKeys as any,
132
+ );
133
+
134
+ expect(result).toMatchObject({
135
+ payloads: [{
136
+ nodeUid: 'volume1~photo1',
137
+ contentHash: 'contentHash',
138
+ nameHash: 'nameHash',
139
+ encryptedName: 'encryptedName',
140
+ nameSignatureEmail: 'test@example.com',
141
+ nodePassphrase: 'passphrase',
142
+ relatedPhotos: [],
143
+ }],
144
+ errors: new Map(),
145
+ });
146
+ expect(nodesService.iterateNodes).toHaveBeenCalledWith(['volume1~photo1'], undefined);
147
+ expect(nodesService.getNodePrivateAndSessionKeys).toHaveBeenCalledWith('volume1~photo1');
148
+ expect(cryptoService.encryptPhotoForAlbum).toHaveBeenCalledTimes(1);
149
+ });
150
+
151
+ it('should include related photos in payload when photo has relatedPhotoNodeUids', async () => {
152
+ const items = [{ photoNodeUid: 'volume1~mainPhoto-related:3' }];
153
+
154
+ const result = await builder.preparePhotoPayloads(
155
+ items,
156
+ 'volume1~root',
157
+ targetKeys as any,
158
+ signingKeys as any,
159
+ );
160
+
161
+ expect(result).toMatchObject({
162
+ payloads: [{
163
+ nodeUid: 'volume1~mainPhoto-related:3',
164
+ contentHash: 'contentHash',
165
+ nameHash: 'nameHash',
166
+ encryptedName: 'encryptedName',
167
+ nameSignatureEmail: 'test@example.com',
168
+ nodePassphrase: 'passphrase',
169
+ relatedPhotos: [{
170
+ nodeUid: 'volume1~related1',
171
+ contentHash: 'contentHash',
172
+ nameHash: 'nameHash',
173
+ encryptedName: 'encryptedName',
174
+ nameSignatureEmail: 'test@example.com',
175
+ nodePassphrase: 'passphrase',
176
+ }, {
177
+ nodeUid: 'volume1~related2',
178
+ contentHash: 'contentHash',
179
+ nameHash: 'nameHash',
180
+ encryptedName: 'encryptedName',
181
+ nameSignatureEmail: 'test@example.com',
182
+ nodePassphrase: 'passphrase',
183
+ }, {
184
+ nodeUid: 'volume1~related3',
185
+ contentHash: 'contentHash',
186
+ nameHash: 'nameHash',
187
+ encryptedName: 'encryptedName',
188
+ nameSignatureEmail: 'test@example.com',
189
+ nodePassphrase: 'passphrase',
190
+ }],
191
+ }],
192
+ errors: new Map(),
193
+ });
194
+ expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2);
195
+ expect(nodesService.iterateNodes).toHaveBeenNthCalledWith(1, ['volume1~mainPhoto-related:3'], undefined);
196
+ expect(nodesService.iterateNodes).toHaveBeenNthCalledWith(
197
+ 2,
198
+ ['volume1~related1', 'volume1~related2', 'volume1~related3'],
199
+ undefined,
200
+ );
201
+ expect(cryptoService.encryptPhotoForAlbum).toHaveBeenCalledTimes(4);
202
+ });
203
+
204
+ it('should merge additionalRelatedPhotoNodeUids with photo relatedPhotoNodeUids', async () => {
205
+ const items = [
206
+ {
207
+ photoNodeUid: 'volume1~photo1',
208
+ additionalRelatedPhotoNodeUids: ['volume1~extraRelated1'],
209
+ },
210
+ ];
211
+
212
+ const result = await builder.preparePhotoPayloads(
213
+ items,
214
+ 'volume1~root',
215
+ targetKeys as any,
216
+ signingKeys as any,
217
+ );
218
+
219
+ expect(result).toMatchObject({
220
+ payloads: [{
221
+ nodeUid: 'volume1~photo1',
222
+ contentHash: 'contentHash',
223
+ nameHash: 'nameHash',
224
+ encryptedName: 'encryptedName',
225
+ nameSignatureEmail: 'test@example.com',
226
+ nodePassphrase: 'passphrase',
227
+ relatedPhotos: [{
228
+ nodeUid: 'volume1~extraRelated1',
229
+ contentHash: 'contentHash',
230
+ nameHash: 'nameHash',
231
+ encryptedName: 'encryptedName',
232
+ nameSignatureEmail: 'test@example.com',
233
+ nodePassphrase: 'passphrase',
234
+ }],
235
+ }],
236
+ errors: new Map(),
237
+ });
238
+ });
239
+
240
+ it('should put missing node UIDs in errors with ValidationError', async () => {
241
+ const items = [
242
+ { photoNodeUid: 'volume1~photo1' },
243
+ { photoNodeUid: 'volume1~missing' },
244
+ ];
245
+
246
+ const result = await builder.preparePhotoPayloads(
247
+ items,
248
+ 'volume1~root',
249
+ targetKeys as any,
250
+ signingKeys as any,
251
+ );
252
+
253
+ expect(result).toMatchObject({
254
+ payloads: [{
255
+ nodeUid: 'volume1~photo1',
256
+ contentHash: 'contentHash',
257
+ nameHash: 'nameHash',
258
+ encryptedName: 'encryptedName',
259
+ nameSignatureEmail: 'test@example.com',
260
+ nodePassphrase: 'passphrase',
261
+ relatedPhotos: [],
262
+ }],
263
+ errors: new Map([['volume1~missing', new ValidationError('Photo not found')]]),
264
+ });
265
+ });
266
+
267
+ it('should throw when targetKeys.hashKey is missing', async () => {
268
+ const items = [{ photoNodeUid: 'volume1~photo1' }];
269
+ const keysWithoutHashKey = { ...targetKeys, hashKey: undefined };
270
+
271
+ await expect(
272
+ builder.preparePhotoPayloads(items, 'volume1~root', keysWithoutHashKey as any, signingKeys as any),
273
+ ).rejects.toThrow('Target hash key is required to build photo payloads');
274
+
275
+ expect(nodesService.iterateNodes).not.toHaveBeenCalled();
276
+ });
277
+
278
+ it('should put error in errors map when encryptPhotoForAlbum fails', async () => {
279
+ const items = [{ photoNodeUid: 'volume1~photo1' }];
280
+ const cryptoError = new Error('Crypto operation failed');
281
+ cryptoService.encryptPhotoForAlbum.mockRejectedValue(cryptoError);
282
+
283
+ const result = await builder.preparePhotoPayloads(
284
+ items,
285
+ 'volume1~root',
286
+ targetKeys as any,
287
+ signingKeys as any,
288
+ );
289
+
290
+ expect(result).toMatchObject({
291
+ payloads: [],
292
+ errors: new Map([['volume1~photo1', cryptoError]]),
293
+ });
294
+ });
295
+
296
+ it('should put error in errors map when getNodePrivateAndSessionKeys fails', async () => {
297
+ const items = [{ photoNodeUid: 'volume1~photo1' }];
298
+ const keysError = new Error('Failed to get keys');
299
+ nodesService.getNodePrivateAndSessionKeys.mockRejectedValue(keysError);
300
+
301
+ const result = await builder.preparePhotoPayloads(
302
+ items,
303
+ 'volume1~root',
304
+ targetKeys as any,
305
+ signingKeys as any,
306
+ );
307
+
308
+ expect(result).toMatchObject({
309
+ payloads: [],
310
+ errors: new Map([['volume1~photo1', keysError]]),
311
+ });
312
+ });
313
+
314
+ it('should put error in errors map when photo has no content hash', async () => {
315
+ const items = [{ photoNodeUid: 'volume1~photo1' }];
316
+ nodesService.iterateNodes.mockImplementation(async function* (uids: string[]) {
317
+ const node = createMockPhotoNode(uids[0]);
318
+ node.activeRevision = { ok: true, value: { ...(node.activeRevision as any).value, claimedDigests: {} } } as any;
319
+ yield node;
320
+ });
321
+
322
+ const result = await builder.preparePhotoPayloads(
323
+ items,
324
+ 'volume1~root',
325
+ targetKeys as any,
326
+ signingKeys as any,
327
+ );
328
+
329
+ expect(result).toMatchObject({
330
+ payloads: [],
331
+ errors: new Map([['volume1~photo1', new Error('Cannot build photo payload without a content hash')]]),
332
+ });
333
+ });
334
+
335
+ it('should include signatureEmail and nodePassphraseSignature only for anonymous key author', async () => {
336
+ const items = [{ photoNodeUid: 'volume1~anonymous' }, { photoNodeUid: 'volume1~signed' }];
337
+ nodesService.iterateNodes.mockImplementation(async function* (uids: string[]) {
338
+ for (const uid of uids) {
339
+ const node = createMockPhotoNode(uid);
340
+ if (uid === 'volume1~anonymous') {
341
+ node.keyAuthor = { ok: true, value: null };
342
+ } else {
343
+ node.keyAuthor = { ok: true, value: 'test@example.com' };
344
+ }
345
+ yield node;
346
+ }
347
+ });
348
+
349
+ const result = await builder.preparePhotoPayloads(
350
+ items,
351
+ 'volume1~root',
352
+ targetKeys as any,
353
+ signingKeys as any,
354
+ );
355
+
356
+ expect(result).toMatchObject({
357
+ payloads: [{
358
+ nodeUid: 'volume1~anonymous',
359
+ contentHash: 'contentHash',
360
+ nameHash: 'nameHash',
361
+ encryptedName: 'encryptedName',
362
+ nameSignatureEmail: 'test@example.com',
363
+ nodePassphrase: 'passphrase',
364
+ signatureEmail: 'test@example.com',
365
+ nodePassphraseSignature: 'signature',
366
+ relatedPhotos: [],
367
+ }, {
368
+ nodeUid: 'volume1~signed',
369
+ contentHash: 'contentHash',
370
+ nameHash: 'nameHash',
371
+ encryptedName: 'encryptedName',
372
+ nameSignatureEmail: 'test@example.com',
373
+ nodePassphrase: 'passphrase',
374
+ relatedPhotos: [],
375
+ }],
376
+ errors: new Map(),
377
+ });
378
+ });
379
+ });
380
+ });
@@ -0,0 +1,203 @@
1
+ import { c } from 'ttag';
2
+
3
+ import { ValidationError } from '../../errors';
4
+ import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface';
5
+ import { AlbumsCryptoService } from './albumsCrypto';
6
+ import { DecryptedPhotoNode } from './interface';
7
+ import { PhotosNodesAccess } from './nodes';
8
+
9
+ export type TransferEncryptedPhotoPayload = TransferEncryptedRelatedPhotoPayload & {
10
+ relatedPhotos: TransferEncryptedRelatedPhotoPayload[];
11
+ };
12
+
13
+ type TransferEncryptedRelatedPhotoPayload = {
14
+ nodeUid: string;
15
+ contentHash: string;
16
+ nameHash: string;
17
+ encryptedName: string;
18
+ nameSignatureEmail: string;
19
+ nodePassphrase: string;
20
+ nodePassphraseSignature?: string;
21
+ signatureEmail?: string;
22
+ }
23
+
24
+ /**
25
+ * Item representing a photo to build a payload for.
26
+ * Used when preparing payloads for add-to-album (with optional retry related UIDs)
27
+ * or for favoriting.
28
+ */
29
+ export type PhotoPayloadItem = {
30
+ photoNodeUid: string;
31
+ /**
32
+ * Additional related photo node UIDs to include (e.g. when retrying after
33
+ * MissingRelatedPhotosError).
34
+ */
35
+ additionalRelatedPhotoNodeUids?: string[];
36
+ };
37
+
38
+ /**
39
+ * Builds encrypted photo payloads (TransferEncryptedPhotoPayload) for a set of
40
+ * photos, including their related photos. Reused by add-to-album and favorite
41
+ * flows, which only differ by the target keys used for encryption.
42
+ */
43
+ export class PhotoTransferPayloadBuilder {
44
+ constructor(
45
+ private readonly cryptoService: AlbumsCryptoService,
46
+ private readonly nodesService: PhotosNodesAccess,
47
+ ) {}
48
+
49
+ /**
50
+ * Prepares encrypted payloads for the given photo items using the provided
51
+ * target keys and signing keys. Used for add-to-album (album keys) and
52
+ * favoriting (volume root keys).
53
+ */
54
+ async preparePhotoPayloads(
55
+ items: PhotoPayloadItem[],
56
+ targetNodeUid: string,
57
+ targetKeys: DecryptedNodeKeys,
58
+ signingKeys: NodeSigningKeys,
59
+ signal?: AbortSignal,
60
+ ): Promise<{
61
+ payloads: TransferEncryptedPhotoPayload[];
62
+ errors: Map<string, Error>;
63
+ }> {
64
+ const payloads: TransferEncryptedPhotoPayload[] = [];
65
+ const errors = new Map<string, Error>();
66
+
67
+ if (!targetKeys.hashKey) {
68
+ throw new Error('Target hash key is required to build photo payloads');
69
+ }
70
+
71
+ const additionalRelatedMap = new Map(
72
+ items.map((item) => [item.photoNodeUid, item.additionalRelatedPhotoNodeUids || []]),
73
+ );
74
+
75
+ const nodeUids = items.map((item) => item.photoNodeUid);
76
+ for await (const photoNode of this.nodesService.iterateNodes(nodeUids, signal)) {
77
+ if ('missingUid' in photoNode) {
78
+ errors.set(photoNode.missingUid, new ValidationError(c('Error').t`Photo not found`));
79
+ continue;
80
+ }
81
+
82
+ if (photoNode.parentUid === targetNodeUid) {
83
+ errors.set(photoNode.uid, new PhotoAlreadyInTargetError());
84
+ continue;
85
+ }
86
+
87
+ try {
88
+ const additionalRelated = additionalRelatedMap.get(photoNode.uid) || [];
89
+ const payload = await this.preparePhotoPayload(
90
+ photoNode,
91
+ additionalRelated,
92
+ targetKeys,
93
+ signingKeys,
94
+ signal,
95
+ );
96
+ payloads.push(payload);
97
+ } catch (error) {
98
+ errors.set(
99
+ photoNode.uid,
100
+ error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }),
101
+ );
102
+ }
103
+ }
104
+
105
+ return { payloads, errors };
106
+ }
107
+
108
+ private async preparePhotoPayload(
109
+ photoNode: DecryptedPhotoNode,
110
+ additionalRelatedPhotoNodeUids: string[],
111
+ targetKeys: DecryptedNodeKeys,
112
+ signingKeys: NodeSigningKeys,
113
+ signal?: AbortSignal,
114
+ ): Promise<TransferEncryptedPhotoPayload> {
115
+ const photoData = await this.encryptPhotoForTarget(photoNode, targetKeys, signingKeys);
116
+
117
+ const relatedNodeUids = [
118
+ ...new Set([
119
+ ...(photoNode.photo?.relatedPhotoNodeUids || []),
120
+ ...additionalRelatedPhotoNodeUids,
121
+ ]),
122
+ ];
123
+
124
+ const relatedPhotos =
125
+ relatedNodeUids.length > 0
126
+ ? await this.prepareRelatedPhotoPayloads(relatedNodeUids, targetKeys, signingKeys, signal)
127
+ : [];
128
+
129
+ return {
130
+ ...photoData,
131
+ relatedPhotos,
132
+ };
133
+ }
134
+
135
+ private async prepareRelatedPhotoPayloads(
136
+ nodeUids: string[],
137
+ targetKeys: DecryptedNodeKeys,
138
+ signingKeys: NodeSigningKeys,
139
+ signal?: AbortSignal,
140
+ ): Promise<Omit<TransferEncryptedPhotoPayload, 'relatedPhotos'>[]> {
141
+ const payloads: Omit<TransferEncryptedPhotoPayload, 'relatedPhotos'>[] = [];
142
+
143
+ for await (const photoNode of this.nodesService.iterateNodes(nodeUids, signal)) {
144
+ if ('missingUid' in photoNode) {
145
+ continue;
146
+ }
147
+ const payload = await this.encryptPhotoForTarget(photoNode, targetKeys, signingKeys);
148
+ payloads.push(payload);
149
+ }
150
+
151
+ return payloads;
152
+ }
153
+
154
+ private async encryptPhotoForTarget(
155
+ photoNode: DecryptedPhotoNode,
156
+ targetKeys: DecryptedNodeKeys,
157
+ signingKeys: NodeSigningKeys,
158
+ ): Promise<Omit<TransferEncryptedPhotoPayload, 'relatedPhotos'>> {
159
+ const nodeKeys = await this.nodesService.getNodePrivateAndSessionKeys(photoNode.uid);
160
+
161
+ const contentSha1 = photoNode.activeRevision?.ok
162
+ ? photoNode.activeRevision.value.claimedDigests?.sha1
163
+ : undefined;
164
+
165
+ if (!contentSha1) {
166
+ throw new Error('Cannot build photo payload without a content hash');
167
+ }
168
+
169
+ const encryptedCrypto = await this.cryptoService.encryptPhotoForAlbum(
170
+ photoNode.name,
171
+ contentSha1,
172
+ nodeKeys,
173
+ { key: targetKeys.key, hashKey: targetKeys.hashKey! },
174
+ signingKeys,
175
+ );
176
+
177
+ const anonymousKey = photoNode.keyAuthor.ok && photoNode.keyAuthor.value === null;
178
+ const keySignatureProperties = !anonymousKey
179
+ ? {}
180
+ : {
181
+ signatureEmail: encryptedCrypto.signatureEmail,
182
+ nodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature,
183
+ };
184
+
185
+ return {
186
+ nodeUid: photoNode.uid,
187
+ contentHash: encryptedCrypto.contentHash,
188
+ nameHash: encryptedCrypto.hash,
189
+ encryptedName: encryptedCrypto.encryptedName,
190
+ nameSignatureEmail: encryptedCrypto.nameSignatureEmail,
191
+ nodePassphrase: encryptedCrypto.armoredNodePassphrase,
192
+ ...keySignatureProperties,
193
+ };
194
+ }
195
+ }
196
+
197
+ export class PhotoAlreadyInTargetError extends ValidationError {
198
+ name = 'PhotoAlreadyInTargetError';
199
+
200
+ constructor() {
201
+ super(c('Error').t`Photo is already in the target album`);
202
+ }
203
+ }
@@ -119,7 +119,12 @@ export class PhotoStreamUploader extends StreamUploader {
119
119
  digests,
120
120
  };
121
121
 
122
- await this.photoUploadManager.commitDraftPhoto(this.revisionDraft, this.manifest, extendedAttributes, this.photoMetadata);
122
+ await this.photoUploadManager.commitDraftPhoto(
123
+ this.revisionDraft,
124
+ await this.getManifest(),
125
+ extendedAttributes,
126
+ this.photoMetadata,
127
+ );
123
128
  }
124
129
  }
125
130
 
@@ -162,22 +162,24 @@ export class UploadAPIService {
162
162
  blocks: {
163
163
  contentBlocks: {
164
164
  index: number;
165
- hash: Uint8Array<ArrayBuffer>;
166
- encryptedSize: number;
167
165
  armoredSignature: string;
168
166
  verificationToken: Uint8Array<ArrayBuffer>;
169
167
  }[];
170
168
  thumbnails?: {
171
169
  type: ThumbnailType;
172
- hash: Uint8Array<ArrayBuffer>;
173
- encryptedSize: number;
174
170
  }[];
175
171
  },
176
172
  ): Promise<UploadTokens> {
177
173
  const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid);
178
174
  const result = await this.apiService.post<
179
175
  // TODO: Deprected fields but not properly marked in the types.
180
- Omit<PostRequestBlockUploadRequest, 'ShareID' | 'Thumbnail' | 'ThumbnailHash' | 'ThumbnailSize'>,
176
+ Omit<
177
+ PostRequestBlockUploadRequest,
178
+ 'ShareID' | 'Thumbnail' | 'ThumbnailHash' | 'ThumbnailSize' | 'BlockList' | 'ThumbnailList'
179
+ > & {
180
+ BlockList: Omit<PostRequestBlockUploadRequest['BlockList'][0], 'Hash' | 'Size'>[];
181
+ ThumbnailList: Omit<PostRequestBlockUploadRequest['ThumbnailList'][0], 'Hash' | 'Size'>[];
182
+ },
181
183
  PostRequestBlockUploadResponse
182
184
  >('drive/blocks', {
183
185
  AddressID: addressId,
@@ -186,16 +188,12 @@ export class UploadAPIService {
186
188
  RevisionID: revisionId,
187
189
  BlockList: blocks.contentBlocks.map((block) => ({
188
190
  Index: block.index,
189
- Hash: uint8ArrayToBase64String(block.hash),
190
191
  EncSignature: block.armoredSignature,
191
- Size: block.encryptedSize,
192
192
  Verifier: {
193
193
  Token: uint8ArrayToBase64String(block.verificationToken),
194
194
  },
195
195
  })),
196
196
  ThumbnailList: (blocks.thumbnails || []).map((block) => ({
197
- Hash: uint8ArrayToBase64String(block.hash),
198
- Size: block.encryptedSize,
199
197
  Type: block.type,
200
198
  })),
201
199
  });
@@ -111,14 +111,14 @@ export class UploadCryptoService {
111
111
  nodeRevisionDraftKeys.signingKeys.contentSigningKey,
112
112
  );
113
113
 
114
- const digest = await crypto.subtle.digest('SHA-256', encryptedData);
114
+ const digestPromise = crypto.subtle.digest('SHA-256', encryptedData);
115
115
 
116
116
  return {
117
117
  type: thumbnail.type,
118
118
  encryptedData: encryptedData,
119
119
  originalSize: thumbnail.thumbnail.length,
120
120
  encryptedSize: encryptedData.length,
121
- hash: new Uint8Array<ArrayBuffer>(digest),
121
+ hashPromise: digestPromise.then((digest) => new Uint8Array<ArrayBuffer>(digest)),
122
122
  };
123
123
  }
124
124
 
@@ -134,8 +134,7 @@ export class UploadCryptoService {
134
134
  nodeRevisionDraftKeys.contentKeyPacketSessionKey,
135
135
  nodeRevisionDraftKeys.signingKeys.contentSigningKey,
136
136
  );
137
-
138
- const digest = await crypto.subtle.digest('SHA-256', encryptedData);
137
+ const digestPromise = crypto.subtle.digest('SHA-256', encryptedData);
139
138
  const { verificationToken } = await verifyBlock(encryptedData);
140
139
 
141
140
  return {
@@ -145,7 +144,7 @@ export class UploadCryptoService {
145
144
  verificationToken,
146
145
  originalSize: block.length,
147
146
  encryptedSize: encryptedData.length,
148
- hash: new Uint8Array<ArrayBuffer>(digest),
147
+ hashPromise: digestPromise.then((digest) => new Uint8Array<ArrayBuffer>(digest)),
149
148
  };
150
149
  }
151
150
 
@@ -64,7 +64,7 @@ export type NodeCryptoSigningKeys = {
64
64
  export type EncryptedBlockMetadata = {
65
65
  encryptedSize: number;
66
66
  originalSize: number;
67
- hash: Uint8Array<ArrayBuffer>;
67
+ hashPromise: Promise<Uint8Array<ArrayBuffer>>;
68
68
  };
69
69
 
70
70
  export type EncryptedBlock = EncryptedBlockMetadata & {