@protontech/drive-sdk 0.9.7 → 0.9.8

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 (52) hide show
  1. package/dist/interface/upload.d.ts +10 -0
  2. package/dist/internal/nodes/mediaTypes.d.ts +2 -0
  3. package/dist/internal/nodes/mediaTypes.js +3 -0
  4. package/dist/internal/nodes/mediaTypes.js.map +1 -1
  5. package/dist/internal/nodes/nodesManagement.js +2 -1
  6. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  7. package/dist/internal/photos/albums.d.ts +12 -1
  8. package/dist/internal/photos/albums.js +80 -1
  9. package/dist/internal/photos/albums.js.map +1 -1
  10. package/dist/internal/photos/albums.test.d.ts +1 -0
  11. package/dist/internal/photos/albums.test.js +193 -0
  12. package/dist/internal/photos/albums.test.js.map +1 -0
  13. package/dist/internal/photos/albumsCrypto.d.ts +35 -0
  14. package/dist/internal/photos/albumsCrypto.js +66 -0
  15. package/dist/internal/photos/albumsCrypto.js.map +1 -0
  16. package/dist/internal/photos/albumsCrypto.test.d.ts +1 -0
  17. package/dist/internal/photos/albumsCrypto.test.js +134 -0
  18. package/dist/internal/photos/albumsCrypto.test.js.map +1 -0
  19. package/dist/internal/photos/apiService.d.ts +18 -0
  20. package/dist/internal/photos/apiService.js +48 -0
  21. package/dist/internal/photos/apiService.js.map +1 -1
  22. package/dist/internal/photos/index.d.ts +3 -3
  23. package/dist/internal/photos/index.js +5 -3
  24. package/dist/internal/photos/index.js.map +1 -1
  25. package/dist/internal/photos/upload.js +3 -2
  26. package/dist/internal/photos/upload.js.map +1 -1
  27. package/dist/internal/upload/streamUploader.d.ts +3 -1
  28. package/dist/internal/upload/streamUploader.js +10 -3
  29. package/dist/internal/upload/streamUploader.js.map +1 -1
  30. package/dist/internal/upload/streamUploader.test.js +10 -0
  31. package/dist/internal/upload/streamUploader.test.js.map +1 -1
  32. package/dist/protonDrivePhotosClient.d.ts +38 -0
  33. package/dist/protonDrivePhotosClient.js +46 -0
  34. package/dist/protonDrivePhotosClient.js.map +1 -1
  35. package/dist/protonDrivePublicLinkClient.d.ts +6 -0
  36. package/dist/protonDrivePublicLinkClient.js +8 -0
  37. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/interface/upload.ts +10 -0
  40. package/src/internal/nodes/mediaTypes.ts +3 -0
  41. package/src/internal/nodes/nodesManagement.ts +2 -1
  42. package/src/internal/photos/albums.test.ts +241 -0
  43. package/src/internal/photos/albums.ts +119 -0
  44. package/src/internal/photos/albumsCrypto.test.ts +181 -0
  45. package/src/internal/photos/albumsCrypto.ts +100 -0
  46. package/src/internal/photos/apiService.ts +94 -2
  47. package/src/internal/photos/index.ts +6 -4
  48. package/src/internal/photos/upload.ts +3 -2
  49. package/src/internal/upload/streamUploader.test.ts +38 -0
  50. package/src/internal/upload/streamUploader.ts +10 -3
  51. package/src/protonDrivePhotosClient.ts +64 -1
  52. package/src/protonDrivePublicLinkClient.ts +14 -0
@@ -0,0 +1,181 @@
1
+ import { DriveCrypto, PrivateKey, SessionKey } from '../../crypto';
2
+ import { NodeSigningKeys } from '../nodes/interface';
3
+ import { AlbumsCryptoService } from './albumsCrypto';
4
+
5
+ describe('AlbumsCryptoService', () => {
6
+ let driveCrypto: DriveCrypto;
7
+ let albumsCryptoService: AlbumsCryptoService;
8
+
9
+ beforeEach(() => {
10
+ jest.clearAllMocks();
11
+
12
+ // @ts-expect-error No need to implement all methods for mocking
13
+ driveCrypto = {};
14
+
15
+ albumsCryptoService = new AlbumsCryptoService(driveCrypto);
16
+ });
17
+
18
+ describe('createAlbum', () => {
19
+ let parentKeys: any;
20
+
21
+ beforeEach(() => {
22
+ parentKeys = {
23
+ key: 'parentKey' as any,
24
+ hashKey: new Uint8Array([1, 2, 3]),
25
+ };
26
+ driveCrypto.generateKey = jest.fn().mockResolvedValue({
27
+ encrypted: {
28
+ armoredKey: 'encryptedNodeKey',
29
+ armoredPassphrase: 'encryptedPassphrase',
30
+ armoredPassphraseSignature: 'passphraseSignature',
31
+ },
32
+ decrypted: {
33
+ key: 'nodeKey' as any,
34
+ passphrase: 'nodePassphrase',
35
+ passphraseSessionKey: 'passphraseSessionKey' as any,
36
+ },
37
+ });
38
+ driveCrypto.encryptNodeName = jest.fn().mockResolvedValue({
39
+ armoredNodeName: 'encryptedNodeName',
40
+ });
41
+ driveCrypto.generateLookupHash = jest.fn().mockResolvedValue('lookupHash');
42
+ driveCrypto.generateHashKey = jest.fn().mockResolvedValue({
43
+ armoredHashKey: 'encryptedHashKey',
44
+ hashKey: new Uint8Array([4, 5, 6]),
45
+ });
46
+ });
47
+
48
+ it('should encrypt new album with user address key', async () => {
49
+ const signingKeys: NodeSigningKeys = {
50
+ type: 'userAddress',
51
+ email: 'test@example.com',
52
+ addressId: 'addressId',
53
+ key: 'addressKey' as any,
54
+ };
55
+
56
+ const result = await albumsCryptoService.createAlbum(parentKeys, signingKeys, 'My Album');
57
+
58
+ expect(result).toEqual({
59
+ encryptedCrypto: {
60
+ encryptedName: 'encryptedNodeName',
61
+ hash: 'lookupHash',
62
+ armoredKey: 'encryptedNodeKey',
63
+ armoredNodePassphrase: 'encryptedPassphrase',
64
+ armoredNodePassphraseSignature: 'passphraseSignature',
65
+ signatureEmail: 'test@example.com',
66
+ armoredHashKey: 'encryptedHashKey',
67
+ },
68
+ keys: {
69
+ passphrase: 'nodePassphrase',
70
+ key: 'nodeKey',
71
+ passphraseSessionKey: 'passphraseSessionKey',
72
+ hashKey: new Uint8Array([4, 5, 6]),
73
+ },
74
+ });
75
+
76
+ expect(driveCrypto.generateKey).toHaveBeenCalledWith([parentKeys.key], signingKeys.key);
77
+ expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith(
78
+ 'My Album',
79
+ undefined,
80
+ parentKeys.key,
81
+ signingKeys.key,
82
+ );
83
+ expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('My Album', parentKeys.hashKey);
84
+ expect(driveCrypto.generateHashKey).toHaveBeenCalledWith('nodeKey');
85
+ });
86
+
87
+ it('should throw error when creating album by anonymous user', async () => {
88
+ const signingKeys: NodeSigningKeys = {
89
+ type: 'nodeKey',
90
+ nodeKey: 'nodeSigningKey' as any,
91
+ parentNodeKey: 'parentNodeKey' as any,
92
+ };
93
+
94
+ await expect(albumsCryptoService.createAlbum(parentKeys, signingKeys, 'My Album')).rejects.toThrow(
95
+ 'Creating album by anonymous user is not supported',
96
+ );
97
+ });
98
+ });
99
+
100
+ describe('renameAlbum', () => {
101
+ let parentKeys: any;
102
+ let nodeNameSessionKey: SessionKey;
103
+
104
+ beforeEach(() => {
105
+ parentKeys = {
106
+ key: 'parentKey' as any,
107
+ hashKey: new Uint8Array([1, 2, 3]),
108
+ };
109
+ nodeNameSessionKey = 'nameSessionKey' as any;
110
+ driveCrypto.decryptSessionKey = jest.fn().mockResolvedValue(nodeNameSessionKey);
111
+ driveCrypto.encryptNodeName = jest.fn().mockResolvedValue({
112
+ armoredNodeName: 'encryptedNewNodeName',
113
+ });
114
+ driveCrypto.generateLookupHash = jest.fn().mockResolvedValue('newHash');
115
+ });
116
+
117
+ it('should encrypt new album name with user address key', async () => {
118
+ const signingKeys: NodeSigningKeys = {
119
+ type: 'userAddress',
120
+ email: 'test@example.com',
121
+ addressId: 'addressId',
122
+ key: 'addressKey' as any,
123
+ };
124
+
125
+ const result = await albumsCryptoService.renameAlbum(
126
+ parentKeys,
127
+ 'oldEncryptedName',
128
+ signingKeys,
129
+ 'Renamed Album',
130
+ );
131
+
132
+ expect(result).toEqual({
133
+ signatureEmail: 'test@example.com',
134
+ armoredNodeName: 'encryptedNewNodeName',
135
+ hash: 'newHash',
136
+ });
137
+
138
+ expect(driveCrypto.decryptSessionKey).toHaveBeenCalledWith('oldEncryptedName', parentKeys.key);
139
+ expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith(
140
+ 'Renamed Album',
141
+ nodeNameSessionKey,
142
+ parentKeys.key,
143
+ signingKeys.key,
144
+ );
145
+ expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('Renamed Album', parentKeys.hashKey);
146
+ });
147
+
148
+ it('should throw error when renaming album by anonymous user', async () => {
149
+ const signingKeys: NodeSigningKeys = {
150
+ type: 'nodeKey',
151
+ nodeKey: 'nodeSigningKey' as any,
152
+ parentNodeKey: 'parentNodeKey' as any,
153
+ };
154
+
155
+ await expect(
156
+ albumsCryptoService.renameAlbum(parentKeys, 'oldEncryptedName', signingKeys, 'Renamed Album'),
157
+ ).rejects.toThrow('Renaming album by anonymous user is not supported');
158
+ });
159
+
160
+ it('should throw error when parent hash key is not available', async () => {
161
+ const parentKeysWithoutHashKey = {
162
+ key: 'parentKey' as any,
163
+ };
164
+ const signingKeys: NodeSigningKeys = {
165
+ type: 'userAddress',
166
+ email: 'test@example.com',
167
+ addressId: 'addressId',
168
+ key: 'addressKey' as any,
169
+ };
170
+
171
+ await expect(
172
+ albumsCryptoService.renameAlbum(
173
+ parentKeysWithoutHashKey,
174
+ 'oldEncryptedName',
175
+ signingKeys,
176
+ 'Renamed Album',
177
+ ),
178
+ ).rejects.toThrow('Cannot rename album: parent folder hash key not available');
179
+ });
180
+ });
181
+ });
@@ -0,0 +1,100 @@
1
+ import { DriveCrypto, PrivateKey } from '../../crypto';
2
+ import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface';
3
+
4
+ /**
5
+ * Provides crypto operations for albums.
6
+ *
7
+ * Albums are special folders in the photos volume. This service reuses
8
+ * the drive crypto module for key and name encryption operations.
9
+ */
10
+ export class AlbumsCryptoService {
11
+ constructor(private driveCrypto: DriveCrypto) {
12
+ this.driveCrypto = driveCrypto;
13
+ }
14
+
15
+ async createAlbum(
16
+ parentKeys: { key: PrivateKey; hashKey: Uint8Array },
17
+ signingKeys: NodeSigningKeys,
18
+ name: string,
19
+ ): Promise<{
20
+ encryptedCrypto: {
21
+ encryptedName: string;
22
+ hash: string;
23
+ armoredKey: string;
24
+ armoredNodePassphrase: string;
25
+ armoredNodePassphraseSignature: string;
26
+ signatureEmail: string;
27
+ armoredHashKey: string;
28
+ };
29
+ keys: DecryptedNodeKeys;
30
+ }> {
31
+ if (signingKeys.type !== 'userAddress') {
32
+ throw new Error('Creating album by anonymous user is not supported');
33
+ }
34
+ const email = signingKeys.email;
35
+ const nameAndPassphraseSigningKey = signingKeys.key;
36
+
37
+ const [nodeKeys, { armoredNodeName }, hash] = await Promise.all([
38
+ this.driveCrypto.generateKey([parentKeys.key], nameAndPassphraseSigningKey),
39
+ this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, nameAndPassphraseSigningKey),
40
+ this.driveCrypto.generateLookupHash(name, parentKeys.hashKey),
41
+ ]);
42
+
43
+ const { armoredHashKey, hashKey } = await this.driveCrypto.generateHashKey(nodeKeys.decrypted.key);
44
+
45
+ return {
46
+ encryptedCrypto: {
47
+ encryptedName: armoredNodeName,
48
+ hash,
49
+ armoredKey: nodeKeys.encrypted.armoredKey,
50
+ armoredNodePassphrase: nodeKeys.encrypted.armoredPassphrase,
51
+ armoredNodePassphraseSignature: nodeKeys.encrypted.armoredPassphraseSignature,
52
+ signatureEmail: email,
53
+ armoredHashKey,
54
+ },
55
+ keys: {
56
+ passphrase: nodeKeys.decrypted.passphrase,
57
+ key: nodeKeys.decrypted.key,
58
+ passphraseSessionKey: nodeKeys.decrypted.passphraseSessionKey,
59
+ hashKey,
60
+ },
61
+ };
62
+ }
63
+
64
+ async renameAlbum(
65
+ parentKeys: { key: PrivateKey; hashKey?: Uint8Array },
66
+ encryptedName: string,
67
+ signingKeys: NodeSigningKeys,
68
+ newName: string,
69
+ ): Promise<{
70
+ signatureEmail: string;
71
+ armoredNodeName: string;
72
+ hash: string;
73
+ }> {
74
+ if (!parentKeys.hashKey) {
75
+ throw new Error('Cannot rename album: parent folder hash key not available');
76
+ }
77
+ if (signingKeys.type !== 'userAddress') {
78
+ throw new Error('Renaming album by anonymous user is not supported');
79
+ }
80
+ const email = signingKeys.email;
81
+ const nameSigningKey = signingKeys.key;
82
+
83
+ const nodeNameSessionKey = await this.driveCrypto.decryptSessionKey(encryptedName, parentKeys.key);
84
+
85
+ const { armoredNodeName } = await this.driveCrypto.encryptNodeName(
86
+ newName,
87
+ nodeNameSessionKey,
88
+ parentKeys.key,
89
+ nameSigningKey,
90
+ );
91
+
92
+ const hash = await this.driveCrypto.generateLookupHash(newName, parentKeys.hashKey);
93
+
94
+ return {
95
+ signatureEmail: email,
96
+ armoredNodeName,
97
+ hash,
98
+ };
99
+ }
100
+ }
@@ -1,6 +1,9 @@
1
- import { DriveAPIService, drivePaths } from '../apiService';
1
+ import { c } from 'ttag';
2
+
3
+ import { ValidationError } from '../../errors';
4
+ import { APICodeError, DriveAPIService, drivePaths } from '../apiService';
2
5
  import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface';
3
- import { makeNodeUid } from '../uids';
6
+ import { makeNodeUid, splitNodeUid } from '../uids';
4
7
 
5
8
  type GetPhotoShareResponse =
6
9
  drivePaths['/drive/v2/shares/photos']['get']['responses']['200']['content']['application/json'];
@@ -18,6 +21,18 @@ type GetTimelineResponse =
18
21
  type GetAlbumsResponse =
19
22
  drivePaths['/drive/photos/volumes/{volumeID}/albums']['get']['responses']['200']['content']['application/json'];
20
23
 
24
+ type PostCreateAlbumRequest = Extract<
25
+ drivePaths['/drive/photos/volumes/{volumeID}/albums']['post']['requestBody'],
26
+ { content: object }
27
+ >['content']['application/json'];
28
+ type PostCreateAlbumResponse =
29
+ drivePaths['/drive/photos/volumes/{volumeID}/albums']['post']['responses']['200']['content']['application/json'];
30
+
31
+ type PutUpdateAlbumRequest = Extract<
32
+ drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}']['put']['requestBody'],
33
+ { content: object }
34
+ >['content']['application/json'];
35
+
21
36
  type PostPhotoDuplicateRequest = Extract<
22
37
  drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['requestBody'],
23
38
  { content: object }
@@ -25,6 +40,8 @@ type PostPhotoDuplicateRequest = Extract<
25
40
  type PostPhotoDuplicateResponse =
26
41
  drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['responses']['200']['content']['application/json'];
27
42
 
43
+ const ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE = 200302;
44
+
28
45
  /**
29
46
  * Provides API communication for fetching and manipulating photos and albums
30
47
  * metadata.
@@ -188,4 +205,79 @@ export class PhotosAPIService {
188
205
  };
189
206
  }).filter((duplicate) => duplicate !== undefined);
190
207
  }
208
+
209
+ async createAlbum(
210
+ parentNodeUid: string,
211
+ album: {
212
+ encryptedName: string;
213
+ hash: string;
214
+ armoredKey: string;
215
+ armoredNodePassphrase: string;
216
+ armoredNodePassphraseSignature: string;
217
+ signatureEmail: string;
218
+ armoredHashKey: string;
219
+ },
220
+ ): Promise<string> {
221
+ const { volumeId } = splitNodeUid(parentNodeUid);
222
+ const response = await this.apiService.post<PostCreateAlbumRequest, PostCreateAlbumResponse>(
223
+ `drive/photos/volumes/${volumeId}/albums`,
224
+ {
225
+ Locked: false,
226
+ Link: {
227
+ Name: album.encryptedName,
228
+ Hash: album.hash,
229
+ NodeKey: album.armoredKey,
230
+ NodePassphrase: album.armoredNodePassphrase,
231
+ NodePassphraseSignature: album.armoredNodePassphraseSignature,
232
+ SignatureEmail: album.signatureEmail,
233
+ NodeHashKey: album.armoredHashKey,
234
+ XAttr: null,
235
+ },
236
+ },
237
+ );
238
+
239
+ return makeNodeUid(volumeId, response.Album.Link.LinkID);
240
+ }
241
+
242
+ async updateAlbum(
243
+ albumNodeUid: string,
244
+ coverPhotoNodeUid?: string,
245
+ updatedName?: {
246
+ encryptedName: string;
247
+ hash: string;
248
+ originalHash: string;
249
+ nameSignatureEmail: string;
250
+ },
251
+ ): Promise<void> {
252
+ const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid);
253
+ const coverLinkId = coverPhotoNodeUid ? splitNodeUid(coverPhotoNodeUid).nodeId : undefined;
254
+ await this.apiService.put<PutUpdateAlbumRequest, void>(
255
+ `drive/photos/volumes/${volumeId}/albums/${linkId}`,
256
+ {
257
+ CoverLinkID: coverLinkId,
258
+ Link: updatedName
259
+ ? {
260
+ Name: updatedName.encryptedName,
261
+ Hash: updatedName.hash,
262
+ OriginalHash: updatedName.originalHash,
263
+ NameSignatureEmail: updatedName.nameSignatureEmail,
264
+ }
265
+ : null,
266
+ },
267
+ );
268
+ }
269
+
270
+ async deleteAlbum(albumNodeUid: string, options: { force?: boolean } = {}): Promise<void> {
271
+ const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid);
272
+ try {
273
+ await this.apiService.delete(
274
+ `drive/photos/volumes/${volumeId}/albums/${linkId}?DeleteAlbumPhotos=${options.force ? 1 : 0}`,
275
+ );
276
+ } catch (error) {
277
+ if (error instanceof APICodeError && error.code === ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE) {
278
+ throw new ValidationError(c('Error').t`Album contains photos not in timeline`);
279
+ }
280
+ throw error;
281
+ }
282
+ }
191
283
  }
@@ -1,4 +1,3 @@
1
- import { DriveAPIService } from '../apiService';
2
1
  import { DriveCrypto } from '../../crypto';
3
2
  import {
4
3
  ProtonDriveAccount,
@@ -6,9 +5,12 @@ import {
6
5
  ProtonDriveEntitiesCache,
7
6
  ProtonDriveTelemetry,
8
7
  } from '../../interface';
8
+ import { DriveAPIService } from '../apiService';
9
9
  import { NodesCryptoService } from '../nodes/cryptoService';
10
10
  import { NodesCryptoReporter } from '../nodes/cryptoReporter';
11
11
  import { NodesCryptoCache } from '../nodes/cryptoCache';
12
+ import { NodesEventsHandler } from '../nodes/events';
13
+ import { NodesRevisons } from '../nodes/nodesRevisions';
12
14
  import { ShareTargetType } from '../shares';
13
15
  import { SharesCache } from '../shares/cache';
14
16
  import { SharesCryptoCache } from '../shares/cryptoCache';
@@ -17,6 +19,7 @@ import { NodesService as UploadNodesService } from '../upload/interface';
17
19
  import { UploadTelemetry } from '../upload/telemetry';
18
20
  import { UploadQueue } from '../upload/queue';
19
21
  import { Albums } from './albums';
22
+ import { AlbumsCryptoService } from './albumsCrypto';
20
23
  import { PhotosAPIService } from './apiService';
21
24
  import { SharesService } from './interface';
22
25
  import { PhotosNodesAPIService, PhotosNodesAccess, PhotosNodesCache, PhotosNodesManagement } from './nodes';
@@ -29,8 +32,6 @@ import {
29
32
  PhotoUploadManager,
30
33
  PhotoUploadMetadata,
31
34
  } from './upload';
32
- import { NodesRevisons } from '../nodes/nodesRevisions';
33
- import { NodesEventsHandler } from '../nodes/events';
34
35
 
35
36
  export type { DecryptedPhotoNode } from './interface';
36
37
 
@@ -51,6 +52,7 @@ export function initPhotosModule(
51
52
  nodesService: PhotosNodesAccess,
52
53
  ) {
53
54
  const api = new PhotosAPIService(apiService);
55
+ const albumsCryptoService = new AlbumsCryptoService(driveCrypto);
54
56
  const timeline = new PhotosTimeline(
55
57
  telemetry.getLogger('photos-timeline'),
56
58
  api,
@@ -58,7 +60,7 @@ export function initPhotosModule(
58
60
  photoShares,
59
61
  nodesService,
60
62
  );
61
- const albums = new Albums(api, photoShares, nodesService);
63
+ const albums = new Albums(api, albumsCryptoService, photoShares, nodesService);
62
64
 
63
65
  return {
64
66
  timeline,
@@ -109,13 +109,14 @@ export class PhotoStreamUploader extends StreamUploader {
109
109
  }
110
110
 
111
111
  async commitFile(thumbnails: Thumbnail[]) {
112
- this.verifyIntegrity(thumbnails);
112
+ const digests = this.digests.digests();
113
+ this.verifyIntegrity(thumbnails, digests);
113
114
 
114
115
  const extendedAttributes = {
115
116
  modificationTime: this.metadata.modificationTime,
116
117
  size: this.metadata.expectedSize,
117
118
  blockSizes: this.uploadedBlockSizes,
118
- digests: this.digests.digests(),
119
+ digests,
119
120
  };
120
121
 
121
122
  await this.photoUploadManager.commitDraftPhoto(this.revisionDraft, this.manifest, extendedAttributes, this.photoMetadata);
@@ -564,6 +564,44 @@ describe('StreamUploader', () => {
564
564
 
565
565
  await verifyFailure('Some file bytes failed to upload', 10 * 1024 * 1024 + 1024);
566
566
  });
567
+
568
+ it('should succeed with matching expectedSha1', async () => {
569
+ metadata.expectedSha1 = '8c206a1a87599f532ce68675536f0b1546900d7a';
570
+
571
+ uploader = new StreamUploader(
572
+ telemetry,
573
+ apiService,
574
+ cryptoService,
575
+ uploadManager,
576
+ blockVerifier,
577
+ revisionDraft,
578
+ metadata,
579
+ onFinish,
580
+ controller,
581
+ abortController,
582
+ );
583
+
584
+ await verifySuccess();
585
+ });
586
+
587
+ it('should throw an error if SHA1 does not match', async () => {
588
+ metadata.expectedSha1 = 'wrong_sha1_hash_that_will_not_match';
589
+
590
+ uploader = new StreamUploader(
591
+ telemetry,
592
+ apiService,
593
+ cryptoService,
594
+ uploadManager,
595
+ blockVerifier,
596
+ revisionDraft,
597
+ metadata,
598
+ onFinish,
599
+ controller,
600
+ abortController,
601
+ );
602
+
603
+ await verifyFailure('File hash does not match expected hash', 10 * 1024 * 1024 + 1024);
604
+ });
567
605
  });
568
606
  });
569
607
 
@@ -219,13 +219,14 @@ export class StreamUploader {
219
219
  }
220
220
 
221
221
  protected async commitFile(thumbnails: Thumbnail[]) {
222
- this.verifyIntegrity(thumbnails);
222
+ const digests = this.digests.digests();
223
+ this.verifyIntegrity(thumbnails, digests);
223
224
 
224
225
  const extendedAttributes = {
225
226
  modificationTime: this.metadata.modificationTime,
226
227
  size: this.metadata.expectedSize,
227
228
  blockSizes: this.uploadedBlockSizes,
228
- digests: this.digests.digests(),
229
+ digests,
229
230
  };
230
231
  await this.uploadManager.commitDraft(
231
232
  this.revisionDraft,
@@ -607,7 +608,7 @@ export class StreamUploader {
607
608
  }
608
609
  }
609
610
 
610
- protected verifyIntegrity(thumbnails: Thumbnail[]) {
611
+ protected verifyIntegrity(thumbnails: Thumbnail[], digests: { sha1: string }) {
611
612
  const expectedBlockCount =
612
613
  Math.ceil(this.metadata.expectedSize / FILE_CHUNK_SIZE) + (thumbnails ? thumbnails?.length : 0);
613
614
  if (this.uploadedBlockCount !== expectedBlockCount) {
@@ -622,6 +623,12 @@ export class StreamUploader {
622
623
  expectedFileSize: this.metadata.expectedSize,
623
624
  });
624
625
  }
626
+ if (this.metadata.expectedSha1 && digests.sha1 !== this.metadata.expectedSha1) {
627
+ throw new IntegrityError(c('Error').t`File hash does not match expected hash`, {
628
+ uploadedSha1: digests.sha1,
629
+ expectedSha1: this.metadata.expectedSha1,
630
+ });
631
+ }
625
632
  }
626
633
 
627
634
  /**
@@ -117,7 +117,13 @@ export class ProtonDrivePhotosClient {
117
117
  this.photoShares,
118
118
  fullConfig.clientUid,
119
119
  );
120
- this.photos = initPhotosModule(telemetry, apiService, cryptoModule, this.photoShares, this.nodes.access);
120
+ this.photos = initPhotosModule(
121
+ telemetry,
122
+ apiService,
123
+ cryptoModule,
124
+ this.photoShares,
125
+ this.nodes.access,
126
+ );
121
127
  this.sharing = initSharingModule(
122
128
  telemetry,
123
129
  apiService,
@@ -507,6 +513,63 @@ export class ProtonDrivePhotosClient {
507
513
  return this.photos.timeline.findPhotoDuplicates(name, generateSha1, signal);
508
514
  }
509
515
 
516
+ /**
517
+ * Creates a new album with the given name.
518
+ *
519
+ * @param name - The name for the new album.
520
+ * @returns The created album node.
521
+ */
522
+ async createAlbum(name: string): Promise<MaybePhotoNode> {
523
+ this.logger.info('Creating album');
524
+ return convertInternalPhotoNodePromise(this.photos.albums.createAlbum(name));
525
+ }
526
+
527
+ /**
528
+ * Updates an existing album.
529
+ *
530
+ * Updates can include a new name and/or a cover photo.
531
+ *
532
+ * @param nodeUid - The UID of the album to edit.
533
+ * @param updates - The updates to apply.
534
+ * @returns The updated album node.
535
+ */
536
+ async updateAlbum(
537
+ nodeUid: NodeOrUid,
538
+ updates: {
539
+ name?: string;
540
+ coverPhotoNodeUid?: NodeOrUid;
541
+ },
542
+ ): Promise<MaybePhotoNode> {
543
+ this.logger.info(`Updating album ${getUid(nodeUid)}`);
544
+ const coverPhotoNodeUid = updates.coverPhotoNodeUid ? getUid(updates.coverPhotoNodeUid) : undefined;
545
+ return convertInternalPhotoNodePromise(
546
+ this.photos.albums.updateAlbum(getUid(nodeUid), {
547
+ name: updates.name,
548
+ coverPhotoNodeUid,
549
+ }),
550
+ );
551
+ }
552
+
553
+ /**
554
+ * Deletes an album.
555
+ *
556
+ * Photos in the timeline will not be deleted. If the album has photos
557
+ * that are not in the timeline (uploaded by another user), the method
558
+ * will throw an error. The photos must be moved to the timeline, or
559
+ * the album must be deleted with `force` option that deletes the photos
560
+ * not in the timeline as well.
561
+ *
562
+ * This operation is irreversible. Both the album and the photos will be
563
+ * permanently deleted, skipping the trash.
564
+ *
565
+ * @param nodeUid - The UID of the album to delete.
566
+ * @param force - Whether to force the deletion.
567
+ */
568
+ async deleteAlbum(nodeUid: NodeOrUid, options: { force?: boolean } = {}): Promise<void> {
569
+ this.logger.info(`Deleting album ${getUid(nodeUid)}`);
570
+ await this.photos.albums.deleteAlbum(getUid(nodeUid), options);
571
+ }
572
+
510
573
  /**
511
574
  * Iterates the albums.
512
575
  *
@@ -70,6 +70,12 @@ export class ProtonDrivePublicLinkClient {
70
70
  * This is used by Docs app to encrypt and decrypt document updates.
71
71
  */
72
72
  getDocsKey: (nodeUid: NodeOrUid) => Promise<SessionKey>;
73
+ /**
74
+ * Experimental feature to get the passphrase for a node.
75
+ *
76
+ * This is used by public link page to report abuse.
77
+ */
78
+ getNodePassphrase: (nodeUid: NodeOrUid) => Promise<string>;
73
79
  /**
74
80
  * Experimental feature to check if hashes match the malware database.
75
81
  */
@@ -175,6 +181,14 @@ export class ProtonDrivePublicLinkClient {
175
181
  }
176
182
  return keys.contentKeyPacketSessionKey;
177
183
  },
184
+ getNodePassphrase: async (nodeUid: NodeOrUid) => {
185
+ this.logger.debug(`Getting node passphrase for ${getUid(nodeUid)}`);
186
+ const keys = await this.sharingPublic.nodes.access.getNodeKeys(getUid(nodeUid));
187
+ if (!keys.passphrase) {
188
+ throw new Error('Node does not have a passphrase');
189
+ }
190
+ return keys.passphrase
191
+ },
178
192
  scanHashes: async (hashes: string[]): Promise<NodesSecurityScanResult> => {
179
193
  this.logger.debug(`Scanning ${hashes.length} hashes`);
180
194
  return this.sharingPublic.nodes.security.scanHashes(hashes);