@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
@@ -1,3 +1,4 @@
1
+ import { ProtonDriveTelemetry } from '../interface';
1
2
  import {
2
3
  OpenPGPCrypto,
3
4
  PrivateKey,
@@ -29,9 +30,11 @@ enum SIGNING_CONTEXTS {
29
30
  */
30
31
  export class DriveCrypto {
31
32
  constructor(
33
+ private telemetry: ProtonDriveTelemetry,
32
34
  private openPGPCrypto: OpenPGPCrypto,
33
35
  private srpModule: SRPModule,
34
36
  ) {
37
+ this.telemetry = telemetry;
35
38
  this.openPGPCrypto = openPGPCrypto;
36
39
  this.srpModule = srpModule;
37
40
  }
@@ -617,12 +620,14 @@ export class DriveCrypto {
617
620
  ): Promise<{
618
621
  encryptedData: Uint8Array<ArrayBuffer>;
619
622
  }> {
623
+ const start = performance.now();
620
624
  const { encryptedData } = await this.openPGPCrypto.encryptAndSign(
621
625
  thumbnailData,
622
626
  sessionKey,
623
627
  [], // Thumbnails use the session key so we do not send encryption key.
624
628
  signingKey,
625
629
  );
630
+ this.recordPerformance('content_encryption', thumbnailData.length, start);
626
631
 
627
632
  return {
628
633
  encryptedData,
@@ -638,11 +643,13 @@ export class DriveCrypto {
638
643
  verified: VERIFICATION_STATUS;
639
644
  verificationErrors?: Error[];
640
645
  }> {
646
+ const start = performance.now();
641
647
  const {
642
648
  data: decryptedThumbnail,
643
649
  verified,
644
650
  verificationErrors,
645
651
  } = await this.openPGPCrypto.decryptAndVerify(encryptedThumbnail, sessionKey, verificationKeys);
652
+ this.recordPerformance('content_decryption', decryptedThumbnail.length, start);
646
653
  return {
647
654
  decryptedThumbnail,
648
655
  verified,
@@ -659,12 +666,14 @@ export class DriveCrypto {
659
666
  encryptedData: Uint8Array<ArrayBuffer>;
660
667
  armoredSignature: string;
661
668
  }> {
669
+ const start = performance.now();
662
670
  const { encryptedData, signature } = await this.openPGPCrypto.encryptAndSignDetached(
663
671
  blockData,
664
672
  sessionKey,
665
673
  [], // Blocks use the session key so we do not send encryption key.
666
674
  signingKey,
667
675
  );
676
+ this.recordPerformance('content_encryption', blockData.length, start);
668
677
 
669
678
  const { armoredSignature } = await this.encryptSignature(signature, encryptionKey, sessionKey);
670
679
 
@@ -674,8 +683,13 @@ export class DriveCrypto {
674
683
  };
675
684
  }
676
685
 
677
- async decryptBlock(encryptedBlock: Uint8Array<ArrayBuffer>, sessionKey: SessionKey): Promise<Uint8Array<ArrayBuffer>> {
686
+ async decryptBlock(
687
+ encryptedBlock: Uint8Array<ArrayBuffer>,
688
+ sessionKey: SessionKey,
689
+ ): Promise<Uint8Array<ArrayBuffer>> {
690
+ const start = performance.now();
678
691
  const { data: decryptedBlock } = await this.openPGPCrypto.decryptAndVerify(encryptedBlock, sessionKey, []);
692
+ this.recordPerformance('content_decryption', decryptedBlock.length, start);
679
693
 
680
694
  return decryptedBlock;
681
695
  }
@@ -716,7 +730,11 @@ export class DriveCrypto {
716
730
  return uint8ArrayToUtf8(password);
717
731
  }
718
732
 
719
- async encryptShareUrlPassword(password: string, encryptionKey: PrivateKey, signingKey: PrivateKey): Promise<string> {
733
+ async encryptShareUrlPassword(
734
+ password: string,
735
+ encryptionKey: PrivateKey,
736
+ signingKey: PrivateKey,
737
+ ): Promise<string> {
720
738
  const { armoredData } = await this.openPGPCrypto.encryptAndSignArmored(
721
739
  new TextEncoder().encode(password),
722
740
  undefined,
@@ -725,6 +743,22 @@ export class DriveCrypto {
725
743
  );
726
744
  return armoredData;
727
745
  }
746
+
747
+ private recordPerformance(
748
+ type: 'content_encryption' | 'content_decryption',
749
+ bytesProcessed: number,
750
+ start: number,
751
+ ) {
752
+ const end = performance.now();
753
+ const duration = end - start;
754
+ this.telemetry.recordMetric({
755
+ eventName: 'performance',
756
+ type,
757
+ cryptoModel: 'v1',
758
+ bytesProcessed,
759
+ milliseconds: Math.round(duration),
760
+ });
761
+ }
728
762
  }
729
763
 
730
764
  export function uint8ArrayToUtf8(input: Uint8Array<ArrayBuffer>): string {
@@ -27,6 +27,9 @@ export class DiagnosticTelemetry extends EventsGenerator {
27
27
  if (event.eventName === 'volumeEventsSubscriptionsChanged') {
28
28
  return;
29
29
  }
30
+ if (event.eventName === 'performance') {
31
+ return;
32
+ }
30
33
 
31
34
  this.enqueueEvent({
32
35
  type: 'metric',
@@ -56,6 +56,7 @@ export type {
56
56
  PhotoAttributes,
57
57
  AlbumAttributes,
58
58
  } from './photos';
59
+ export { PhotoTag } from './photos';
59
60
  export type {
60
61
  ProtonInvitation,
61
62
  ProtonInvitationWithNode,
@@ -65,7 +65,20 @@ export type PhotoAttributes = {
65
65
  /**
66
66
  * List of tags assigned to the photo.
67
67
  */
68
- tags: number[]; // TODO: enum
68
+ tags: PhotoTag[];
69
+ };
70
+
71
+ export enum PhotoTag {
72
+ Favorites = 0,
73
+ Screenshots = 1,
74
+ Videos = 2,
75
+ LivePhotos = 3,
76
+ MotionPhotos = 4,
77
+ Selfies = 5,
78
+ Portraits = 6,
79
+ Bursts = 7,
80
+ Panoramas = 8,
81
+ Raw = 9,
69
82
  }
70
83
 
71
84
  /**
@@ -18,7 +18,8 @@ export type MetricEvent =
18
18
  | MetricDecryptionErrorEvent
19
19
  | MetricVerificationErrorEvent
20
20
  | MetricBlockVerificationErrorEvent
21
- | MetricVolumeEventsSubscriptionsChangedEvent;
21
+ | MetricVolumeEventsSubscriptionsChangedEvent
22
+ | MetricPerformanceEvent;
22
23
 
23
24
  export interface MetricAPIRetrySucceededEvent {
24
25
  eventName: 'apiRetrySucceeded';
@@ -118,3 +119,15 @@ export enum MetricVolumeType {
118
119
  Shared = 'shared',
119
120
  SharedPublic = 'shared_public',
120
121
  }
122
+
123
+ /**
124
+ * Experimental metrics to track performance of encryption and decryption
125
+ * operations of the file content.
126
+ */
127
+ export interface MetricPerformanceEvent {
128
+ eventName: 'performance';
129
+ type: 'content_encryption' | 'content_decryption';
130
+ cryptoModel: 'v1' | 'v1.5';
131
+ bytesProcessed: number;
132
+ milliseconds: number;
133
+ }
@@ -137,8 +137,12 @@ export class DriveAPIService {
137
137
  return this.makeRequest(url, 'PUT', data, signal);
138
138
  }
139
139
 
140
- async delete<Response>(url: string, signal?: AbortSignal): Promise<Response> {
141
- return this.makeRequest(url, 'DELETE', undefined, signal);
140
+ async delete<RequestPayload, ResponsePayload>(
141
+ url: string,
142
+ data?: RequestPayload,
143
+ signal?: AbortSignal,
144
+ ): Promise<ResponsePayload> {
145
+ return this.makeRequest(url, 'DELETE', data, signal);
142
146
  }
143
147
 
144
148
  protected async makeRequest<RequestPayload, ResponsePayload>(
@@ -440,7 +440,7 @@ export abstract class NodeAPIServiceBase<
440
440
  }
441
441
 
442
442
  async emptyTrash(volumeId: string): Promise<void> {
443
- await this.apiService.delete<EmptyTrashResponse>(`drive/volumes/${volumeId}/trash`);
443
+ await this.apiService.delete<undefined, EmptyTrashResponse>(`drive/volumes/${volumeId}/trash`);
444
444
  }
445
445
 
446
446
  async *restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
@@ -561,7 +561,7 @@ export abstract class NodeAPIServiceBase<
561
561
  async deleteRevision(nodeRevisionUid: string): Promise<void> {
562
562
  const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid);
563
563
 
564
- await this.apiService.delete<DeleteRevisionResponse>(
564
+ await this.apiService.delete<undefined, DeleteRevisionResponse>(
565
565
  `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`,
566
566
  );
567
567
  }
@@ -412,7 +412,7 @@ export abstract class NodesAccessBase<
412
412
  }
413
413
  // This is bug that should not happen.
414
414
  // API cannot provide node without parent or share.
415
- throw new Error('Node has neither parent node nor share');
415
+ throw new Error(`Node has neither parent node nor share: ${node.uid}`);
416
416
  }
417
417
 
418
418
  async getNodeKeys(nodeUid: string): Promise<DecryptedNodeKeys> {
@@ -1,13 +1,12 @@
1
1
  import { c } from 'ttag';
2
2
 
3
- import { ValidationError } from '../../errors';
4
3
  import { Logger, NodeResultWithError } from '../../interface';
5
4
  import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface';
6
5
  import { splitNodeUid } from '../uids';
7
6
  import { AlbumsCryptoService } from './albumsCrypto';
8
7
  import { PhotosAPIService } from './apiService';
9
8
  import { MissingRelatedPhotosError } from './errors';
10
- import { AddToAlbumEncryptedPhotoPayload, DecryptedPhotoNode } from './interface';
9
+ import { PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder';
11
10
  import { PhotosNodesAccess } from './nodes';
12
11
 
13
12
  /**
@@ -47,18 +46,20 @@ type PhotoQueueItem = {
47
46
  export class AddToAlbumProcess {
48
47
  private readonly albumVolumeId: string;
49
48
  private readonly retriedPhotoUids = new Set<string>();
49
+ private readonly payloadBuilder: PhotoTransferPayloadBuilder;
50
50
 
51
51
  constructor(
52
52
  private readonly albumNodeUid: string,
53
53
  private readonly albumKeys: DecryptedNodeKeys,
54
54
  private readonly signingKeys: NodeSigningKeys,
55
55
  private readonly apiService: PhotosAPIService,
56
- private readonly cryptoService: AlbumsCryptoService,
56
+ cryptoService: AlbumsCryptoService,
57
57
  private readonly nodesService: PhotosNodesAccess,
58
58
  private readonly logger: Logger,
59
59
  private readonly signal?: AbortSignal,
60
60
  ) {
61
61
  this.albumVolumeId = splitNodeUid(albumNodeUid).volumeId;
62
+ this.payloadBuilder = new PhotoTransferPayloadBuilder(cryptoService, nodesService);
62
63
  }
63
64
 
64
65
  async *execute(photoNodeUids: string[]): AsyncGenerator<NodeResultWithError> {
@@ -71,7 +72,13 @@ export class AddToAlbumProcess {
71
72
  private async *processSameVolumeQueue(queue: PhotoQueueItem[]): AsyncGenerator<NodeResultWithError> {
72
73
  while (queue.length > 0) {
73
74
  const items = queue.splice(0, BATCH_LOADING_SIZE);
74
- const { payloads, errors } = await this.preparePhotoPayloads(items);
75
+ const { payloads, errors } = await this.payloadBuilder.preparePhotoPayloads(
76
+ items,
77
+ this.albumNodeUid,
78
+ this.albumKeys,
79
+ this.signingKeys,
80
+ this.signal,
81
+ );
75
82
 
76
83
  for (const [uid, error] of errors) {
77
84
  yield { uid, ok: false, error };
@@ -97,7 +104,13 @@ export class AddToAlbumProcess {
97
104
  private async *processDifferentVolumeQueue(queue: PhotoQueueItem[]): AsyncGenerator<NodeResultWithError> {
98
105
  while (queue.length > 0) {
99
106
  const items = queue.splice(0, BATCH_LOADING_SIZE);
100
- const { payloads, errors } = await this.preparePhotoPayloads(items);
107
+ const { payloads, errors } = await this.payloadBuilder.preparePhotoPayloads(
108
+ items,
109
+ this.albumNodeUid,
110
+ this.albumKeys,
111
+ this.signingKeys,
112
+ this.signal,
113
+ );
101
114
 
102
115
  for (const [uid, error] of errors) {
103
116
  yield { uid, ok: false, error };
@@ -105,7 +118,11 @@ export class AddToAlbumProcess {
105
118
 
106
119
  for (const payload of payloads) {
107
120
  try {
108
- const newPhotoNodeUid = await this.apiService.copyPhotoToAlbum(this.albumNodeUid, payload, this.signal);
121
+ const newPhotoNodeUid = await this.apiService.copyPhotoToAlbum(
122
+ this.albumNodeUid,
123
+ payload,
124
+ this.signal,
125
+ );
109
126
  await this.nodesService.notifyChildCreated(newPhotoNodeUid);
110
127
  yield { uid: payload.nodeUid, ok: true };
111
128
  } catch (error) {
@@ -119,131 +136,14 @@ export class AddToAlbumProcess {
119
136
  yield {
120
137
  uid: payload.nodeUid,
121
138
  ok: false,
122
- error: error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }),
139
+ error:
140
+ error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }),
123
141
  };
124
142
  }
125
143
  }
126
144
  }
127
145
  }
128
146
 
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
147
  /**
248
148
  * If the result indicates a MissingRelatedPhotosError that hasn't
249
149
  * been retried, returns a retry queue item. Otherwise returns undefined.
@@ -260,19 +160,14 @@ export class AddToAlbumProcess {
260
160
  * Returns undefined if the photo has already been retried, preventing
261
161
  * infinite retry loops.
262
162
  */
263
- private createRetryQueueItem(
264
- photoNodeUid: string,
265
- error: MissingRelatedPhotosError,
266
- ): PhotoQueueItem | undefined {
163
+ private createRetryQueueItem(photoNodeUid: string, error: MissingRelatedPhotosError): PhotoQueueItem | undefined {
267
164
  if (this.retriedPhotoUids.has(photoNodeUid)) {
268
165
  this.logger.warn(`Missing related photos for ${photoNodeUid}, already retried`);
269
166
  return undefined;
270
167
  }
271
168
 
272
169
  this.retriedPhotoUids.add(photoNodeUid);
273
- this.logger.info(
274
- `Missing related photos for ${photoNodeUid}, re-queuing: ${error.missingNodeUids.join(', ')}`,
275
- );
170
+ this.logger.info(`Missing related photos for ${photoNodeUid}, re-queuing: ${error.missingNodeUids.join(', ')}`);
276
171
 
277
172
  return {
278
173
  photoNodeUid,
@@ -316,10 +211,8 @@ function splitByVolume(
316
211
  * Groups payloads into batches respecting the API limit.
317
212
  * Each payload's size counts itself plus its related photos.
318
213
  */
319
- function* createBatches(
320
- payloads: AddToAlbumEncryptedPhotoPayload[],
321
- ): Generator<AddToAlbumEncryptedPhotoPayload[]> {
322
- let batch: AddToAlbumEncryptedPhotoPayload[] = [];
214
+ function* createBatches(payloads: TransferEncryptedPhotoPayload[]): Generator<TransferEncryptedPhotoPayload[]> {
215
+ let batch: TransferEncryptedPhotoPayload[] = [];
323
216
  let batchSize = 0;
324
217
 
325
218
  for (const payload of payloads) {
@@ -1,7 +1,7 @@
1
1
  import { NodeType } from '../../interface';
2
2
  import { ValidationError } from '../../errors';
3
3
  import { getMockTelemetry } from '../../tests/telemetry';
4
- import { Albums } from './albums';
4
+ import { AlbumsManager } from './albumsManager';
5
5
  import { AlbumsCryptoService } from './albumsCrypto';
6
6
  import { PhotosAPIService } from './apiService';
7
7
  import { DecryptedPhotoNode } from './interface';
@@ -13,7 +13,7 @@ describe('Albums', () => {
13
13
  let cryptoService: AlbumsCryptoService;
14
14
  let photoShares: PhotoSharesManager;
15
15
  let nodesService: PhotosNodesAccess;
16
- let albums: Albums;
16
+ let albums: AlbumsManager;
17
17
 
18
18
  let nodes: { [uid: string]: DecryptedPhotoNode };
19
19
 
@@ -97,7 +97,7 @@ describe('Albums', () => {
97
97
  notifyChildCreated: jest.fn(),
98
98
  };
99
99
 
100
- albums = new Albums(getMockTelemetry(), apiService, cryptoService, photoShares, nodesService);
100
+ albums = new AlbumsManager(getMockTelemetry(), apiService, cryptoService, photoShares, nodesService);
101
101
  });
102
102
 
103
103
  describe('createAlbum', () => {
@@ -16,7 +16,7 @@ const BATCH_LOADING_SIZE = 10;
16
16
  /**
17
17
  * Provides access and high-level actions for managing albums.
18
18
  */
19
- export class Albums {
19
+ export class AlbumsManager {
20
20
  private logger: Logger;
21
21
 
22
22
  constructor(
@@ -1,13 +1,14 @@
1
1
  import { c } from 'ttag';
2
2
 
3
3
  import { ValidationError } from '../../errors';
4
- import { NodeResultWithError } from '../../interface';
4
+ import { NodeResultWithError, PhotoTag } from '../../interface';
5
5
  import { APICodeError, DriveAPIService, drivePaths, InvalidRequirementsAPIError, isCodeOk } from '../apiService';
6
6
  import { batch } from '../batch';
7
7
  import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface';
8
8
  import { makeNodeUid, splitNodeUid } from '../uids';
9
9
  import { MissingRelatedPhotosError } from './errors';
10
- import { AddToAlbumEncryptedPhotoPayload, AlbumItem } from './interface';
10
+ import { AlbumItem } from './interface';
11
+ import { TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder';
11
12
 
12
13
  type GetPhotoShareResponse =
13
14
  drivePaths['/drive/v2/shares/photos']['get']['responses']['200']['content']['application/json'];
@@ -68,6 +69,19 @@ type PostRemovePhotosFromAlbumRequest = Extract<
68
69
  type PostRemovePhotosFromAlbumResponse =
69
70
  drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple']['post']['responses']['200']['content']['application/json'];
70
71
 
72
+ type PostAddPhotoTagsRequest = Extract<
73
+ drivePaths['/drive/photos/volumes/{volumeID}/links/{linkID}/tags']['post']['requestBody'],
74
+ { content: object }
75
+ >['content']['application/json'];
76
+ type PostRemovePhotoTagsRequest = Extract<
77
+ drivePaths['/drive/photos/volumes/{volumeID}/links/{linkID}/tags']['delete']['requestBody'],
78
+ { content: object }
79
+ >['content']['application/json'];
80
+ type PostFavoritePhotoRequest = Extract<
81
+ drivePaths['/drive/photos/volumes/{volumeID}/links/{linkID}/favorite']['post']['requestBody'],
82
+ { content: object }
83
+ >['content']['application/json'];
84
+
71
85
  const ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE = 200302;
72
86
 
73
87
  /**
@@ -337,15 +351,12 @@ export class PhotosAPIService {
337
351
  */
338
352
  async *addPhotosToAlbum(
339
353
  albumNodeUid: string,
340
- photoPayloads: AddToAlbumEncryptedPhotoPayload[],
354
+ photoPayloads: TransferEncryptedPhotoPayload[],
341
355
  signal?: AbortSignal,
342
356
  ): AsyncGenerator<NodeResultWithError> {
343
357
  const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid);
344
358
 
345
- const allPhotoPayloads = photoPayloads.flatMap((photoPayload) => [
346
- photoPayload,
347
- ...(photoPayload.relatedPhotos || []),
348
- ]);
359
+ const allPhotoPayloads = photoPayloads.flatMap((photoPayload) => [photoPayload, ...photoPayload.relatedPhotos]);
349
360
  const allPhotoData = allPhotoPayloads.map((photoPayload) => {
350
361
  const { nodeId } = splitNodeUid(photoPayload.nodeUid);
351
362
  return {
@@ -416,7 +427,7 @@ export class PhotosAPIService {
416
427
  */
417
428
  async copyPhotoToAlbum(
418
429
  albumNodeUid: string,
419
- payload: AddToAlbumEncryptedPhotoPayload,
430
+ payload: TransferEncryptedPhotoPayload,
420
431
  signal?: AbortSignal,
421
432
  ): Promise<string> {
422
433
  const { volumeId: sourceVolumeId, nodeId: sourceLinkId } = splitNodeUid(payload.nodeUid);
@@ -438,14 +449,13 @@ export class PhotosAPIService {
438
449
  SignatureEmail: payload.signatureEmail,
439
450
  Photos: {
440
451
  ContentHash: payload.contentHash,
441
- RelatedPhotos:
442
- payload.relatedPhotos?.map((related) => ({
443
- LinkID: splitNodeUid(related.nodeUid).nodeId,
444
- Hash: related.nameHash,
445
- Name: related.encryptedName,
446
- NodePassphrase: related.nodePassphrase,
447
- ContentHash: related.contentHash,
448
- })) || [],
452
+ RelatedPhotos: payload.relatedPhotos.map((related) => ({
453
+ LinkID: splitNodeUid(related.nodeUid).nodeId,
454
+ Hash: related.nameHash,
455
+ Name: related.encryptedName,
456
+ NodePassphrase: related.nodePassphrase,
457
+ ContentHash: related.contentHash,
458
+ })),
449
459
  },
450
460
  },
451
461
  signal,
@@ -499,4 +509,51 @@ export class PhotosAPIService {
499
509
  }
500
510
  }
501
511
  }
512
+
513
+ async addPhotoTags(nodeUid: string, tags: PhotoTag[]): Promise<void> {
514
+ const { volumeId, nodeId: linkId } = splitNodeUid(nodeUid);
515
+ await this.apiService.post<PostAddPhotoTagsRequest, { Code: number }>(
516
+ `drive/photos/volumes/${volumeId}/links/${linkId}/tags`,
517
+ { Tags: tags },
518
+ );
519
+ }
520
+
521
+ async removePhotoTags(nodeUid: string, tags: PhotoTag[]): Promise<void> {
522
+ const { volumeId, nodeId: linkId } = splitNodeUid(nodeUid);
523
+ await this.apiService.delete<PostRemovePhotoTagsRequest, { Code: number }>(
524
+ `drive/photos/volumes/${volumeId}/links/${linkId}/tags`,
525
+ { Tags: tags },
526
+ );
527
+ }
528
+
529
+ async setPhotoFavorite(nodeUid: string, payload?: TransferEncryptedPhotoPayload): Promise<void> {
530
+ const { volumeId, nodeId: linkId } = splitNodeUid(nodeUid);
531
+ const requestBody = payload
532
+ ? {
533
+ PhotoData: {
534
+ Hash: payload.nameHash,
535
+ Name: payload.encryptedName,
536
+ NameSignatureEmail: payload.nameSignatureEmail,
537
+ NodePassphrase: payload.nodePassphrase,
538
+ ContentHash: payload.contentHash,
539
+ NodePassphraseSignature: payload.nodePassphraseSignature ?? null,
540
+ SignatureEmail: payload.signatureEmail ?? null,
541
+ RelatedPhotos: payload.relatedPhotos.map((related) => ({
542
+ LinkID: splitNodeUid(related.nodeUid).nodeId,
543
+ Hash: related.nameHash,
544
+ Name: related.encryptedName,
545
+ NameSignatureEmail: related.nameSignatureEmail,
546
+ NodePassphrase: related.nodePassphrase,
547
+ ContentHash: related.contentHash,
548
+ NodePassphraseSignature: related.nodePassphraseSignature ?? null,
549
+ SignatureEmail: related.signatureEmail ?? null,
550
+ })),
551
+ },
552
+ }
553
+ : undefined;
554
+ await this.apiService.post<PostFavoritePhotoRequest, { Code: number }>(
555
+ `drive/photos/volumes/${volumeId}/links/${linkId}/favorite`,
556
+ requestBody,
557
+ );
558
+ }
502
559
  }