@protontech/drive-sdk 0.9.7 → 0.9.9
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.
- package/dist/interface/upload.d.ts +10 -0
- package/dist/internal/nodes/mediaTypes.d.ts +2 -0
- package/dist/internal/nodes/mediaTypes.js +3 -0
- package/dist/internal/nodes/mediaTypes.js.map +1 -1
- package/dist/internal/nodes/nodesManagement.js +2 -1
- package/dist/internal/nodes/nodesManagement.js.map +1 -1
- package/dist/internal/photos/albums.d.ts +15 -1
- package/dist/internal/photos/albums.js +91 -1
- package/dist/internal/photos/albums.js.map +1 -1
- package/dist/internal/photos/albums.test.d.ts +1 -0
- package/dist/internal/photos/albums.test.js +217 -0
- package/dist/internal/photos/albums.test.js.map +1 -0
- package/dist/internal/photos/albumsCrypto.d.ts +35 -0
- package/dist/internal/photos/albumsCrypto.js +66 -0
- package/dist/internal/photos/albumsCrypto.js.map +1 -0
- package/dist/internal/photos/albumsCrypto.test.d.ts +1 -0
- package/dist/internal/photos/albumsCrypto.test.js +134 -0
- package/dist/internal/photos/albumsCrypto.test.js.map +1 -0
- package/dist/internal/photos/apiService.d.ts +22 -0
- package/dist/internal/photos/apiService.js +91 -0
- package/dist/internal/photos/apiService.js.map +1 -1
- package/dist/internal/photos/index.d.ts +4 -4
- package/dist/internal/photos/index.js +5 -3
- package/dist/internal/photos/index.js.map +1 -1
- package/dist/internal/photos/interface.d.ts +21 -0
- package/dist/internal/photos/interface.js +14 -0
- package/dist/internal/photos/interface.js.map +1 -1
- package/dist/internal/photos/timeline.d.ts +2 -5
- package/dist/internal/photos/timeline.js.map +1 -1
- package/dist/internal/photos/upload.js +3 -2
- package/dist/internal/photos/upload.js.map +1 -1
- package/dist/internal/upload/streamUploader.d.ts +3 -1
- package/dist/internal/upload/streamUploader.js +10 -3
- package/dist/internal/upload/streamUploader.js.map +1 -1
- package/dist/internal/upload/streamUploader.test.js +10 -0
- package/dist/internal/upload/streamUploader.test.js.map +1 -1
- package/dist/protonDrivePhotosClient.d.ts +64 -6
- package/dist/protonDrivePhotosClient.js +75 -1
- package/dist/protonDrivePhotosClient.js.map +1 -1
- package/dist/protonDrivePublicLinkClient.d.ts +15 -0
- package/dist/protonDrivePublicLinkClient.js +20 -0
- package/dist/protonDrivePublicLinkClient.js.map +1 -1
- package/package.json +1 -1
- package/src/interface/upload.ts +10 -0
- package/src/internal/nodes/mediaTypes.ts +3 -0
- package/src/internal/nodes/nodesManagement.ts +2 -1
- package/src/internal/photos/albums.test.ts +268 -0
- package/src/internal/photos/albums.ts +136 -0
- package/src/internal/photos/albumsCrypto.test.ts +181 -0
- package/src/internal/photos/albumsCrypto.ts +100 -0
- package/src/internal/photos/apiService.ts +168 -2
- package/src/internal/photos/index.ts +7 -5
- package/src/internal/photos/interface.ts +24 -0
- package/src/internal/photos/timeline.ts +2 -5
- package/src/internal/photos/upload.ts +3 -2
- package/src/internal/upload/streamUploader.test.ts +38 -0
- package/src/internal/upload/streamUploader.ts +10 -3
- package/src/protonDrivePhotosClient.ts +104 -9
- package/src/protonDrivePublicLinkClient.ts +27 -0
|
@@ -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,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { c } from 'ttag';
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '../../errors';
|
|
4
|
+
import { NodeResult } from '../../interface';
|
|
5
|
+
import { APICodeError, DriveAPIService, drivePaths } from '../apiService';
|
|
6
|
+
import { batch } from '../batch';
|
|
2
7
|
import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface';
|
|
3
|
-
import { makeNodeUid } from '../uids';
|
|
8
|
+
import { makeNodeUid, splitNodeUid } from '../uids';
|
|
9
|
+
import { AlbumItem } from './interface';
|
|
4
10
|
|
|
5
11
|
type GetPhotoShareResponse =
|
|
6
12
|
drivePaths['/drive/v2/shares/photos']['get']['responses']['200']['content']['application/json'];
|
|
@@ -18,6 +24,21 @@ type GetTimelineResponse =
|
|
|
18
24
|
type GetAlbumsResponse =
|
|
19
25
|
drivePaths['/drive/photos/volumes/{volumeID}/albums']['get']['responses']['200']['content']['application/json'];
|
|
20
26
|
|
|
27
|
+
type GetAlbumChildrenResponse =
|
|
28
|
+
drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/children']['get']['responses']['200']['content']['application/json'];
|
|
29
|
+
|
|
30
|
+
type PostCreateAlbumRequest = Extract<
|
|
31
|
+
drivePaths['/drive/photos/volumes/{volumeID}/albums']['post']['requestBody'],
|
|
32
|
+
{ content: object }
|
|
33
|
+
>['content']['application/json'];
|
|
34
|
+
type PostCreateAlbumResponse =
|
|
35
|
+
drivePaths['/drive/photos/volumes/{volumeID}/albums']['post']['responses']['200']['content']['application/json'];
|
|
36
|
+
|
|
37
|
+
type PutUpdateAlbumRequest = Extract<
|
|
38
|
+
drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}']['put']['requestBody'],
|
|
39
|
+
{ content: object }
|
|
40
|
+
>['content']['application/json'];
|
|
41
|
+
|
|
21
42
|
type PostPhotoDuplicateRequest = Extract<
|
|
22
43
|
drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['requestBody'],
|
|
23
44
|
{ content: object }
|
|
@@ -25,6 +46,15 @@ type PostPhotoDuplicateRequest = Extract<
|
|
|
25
46
|
type PostPhotoDuplicateResponse =
|
|
26
47
|
drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['responses']['200']['content']['application/json'];
|
|
27
48
|
|
|
49
|
+
type PostRemovePhotosFromAlbumRequest = Extract<
|
|
50
|
+
drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple']['post']['requestBody'],
|
|
51
|
+
{ content: object }
|
|
52
|
+
>['content']['application/json'];
|
|
53
|
+
type PostRemovePhotosFromAlbumResponse =
|
|
54
|
+
drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple']['post']['responses']['200']['content']['application/json'];
|
|
55
|
+
|
|
56
|
+
const ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE = 200302;
|
|
57
|
+
|
|
28
58
|
/**
|
|
29
59
|
* Provides API communication for fetching and manipulating photos and albums
|
|
30
60
|
* metadata.
|
|
@@ -156,6 +186,31 @@ export class PhotosAPIService {
|
|
|
156
186
|
}
|
|
157
187
|
}
|
|
158
188
|
|
|
189
|
+
async *iterateAlbumChildren(
|
|
190
|
+
albumNodeUid: string,
|
|
191
|
+
signal?: AbortSignal,
|
|
192
|
+
): AsyncGenerator<AlbumItem> {
|
|
193
|
+
const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid);
|
|
194
|
+
let anchor = '';
|
|
195
|
+
while (true) {
|
|
196
|
+
const response = await this.apiService.get<GetAlbumChildrenResponse>(
|
|
197
|
+
`drive/photos/volumes/${volumeId}/albums/${linkId}/children?Sort=Captured&Desc=1${anchor ? `&AnchorID=${anchor}` : ''}`,
|
|
198
|
+
signal,
|
|
199
|
+
);
|
|
200
|
+
for (const photo of response.Photos) {
|
|
201
|
+
yield {
|
|
202
|
+
nodeUid: makeNodeUid(volumeId, photo.LinkID),
|
|
203
|
+
captureTime: new Date(photo.CaptureTime * 1000),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!response.More || !response.AnchorID) {
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
anchor = response.AnchorID;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
159
214
|
async checkPhotoDuplicates(
|
|
160
215
|
volumeId: string,
|
|
161
216
|
nameHashes: string[],
|
|
@@ -188,4 +243,115 @@ export class PhotosAPIService {
|
|
|
188
243
|
};
|
|
189
244
|
}).filter((duplicate) => duplicate !== undefined);
|
|
190
245
|
}
|
|
246
|
+
|
|
247
|
+
async createAlbum(
|
|
248
|
+
parentNodeUid: string,
|
|
249
|
+
album: {
|
|
250
|
+
encryptedName: string;
|
|
251
|
+
hash: string;
|
|
252
|
+
armoredKey: string;
|
|
253
|
+
armoredNodePassphrase: string;
|
|
254
|
+
armoredNodePassphraseSignature: string;
|
|
255
|
+
signatureEmail: string;
|
|
256
|
+
armoredHashKey: string;
|
|
257
|
+
},
|
|
258
|
+
): Promise<string> {
|
|
259
|
+
const { volumeId } = splitNodeUid(parentNodeUid);
|
|
260
|
+
const response = await this.apiService.post<PostCreateAlbumRequest, PostCreateAlbumResponse>(
|
|
261
|
+
`drive/photos/volumes/${volumeId}/albums`,
|
|
262
|
+
{
|
|
263
|
+
Locked: false,
|
|
264
|
+
Link: {
|
|
265
|
+
Name: album.encryptedName,
|
|
266
|
+
Hash: album.hash,
|
|
267
|
+
NodeKey: album.armoredKey,
|
|
268
|
+
NodePassphrase: album.armoredNodePassphrase,
|
|
269
|
+
NodePassphraseSignature: album.armoredNodePassphraseSignature,
|
|
270
|
+
SignatureEmail: album.signatureEmail,
|
|
271
|
+
NodeHashKey: album.armoredHashKey,
|
|
272
|
+
XAttr: null,
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
return makeNodeUid(volumeId, response.Album.Link.LinkID);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async updateAlbum(
|
|
281
|
+
albumNodeUid: string,
|
|
282
|
+
coverPhotoNodeUid?: string,
|
|
283
|
+
updatedName?: {
|
|
284
|
+
encryptedName: string;
|
|
285
|
+
hash: string;
|
|
286
|
+
originalHash: string;
|
|
287
|
+
nameSignatureEmail: string;
|
|
288
|
+
},
|
|
289
|
+
): Promise<void> {
|
|
290
|
+
const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid);
|
|
291
|
+
const coverLinkId = coverPhotoNodeUid ? splitNodeUid(coverPhotoNodeUid).nodeId : undefined;
|
|
292
|
+
await this.apiService.put<PutUpdateAlbumRequest, void>(
|
|
293
|
+
`drive/photos/volumes/${volumeId}/albums/${linkId}`,
|
|
294
|
+
{
|
|
295
|
+
CoverLinkID: coverLinkId,
|
|
296
|
+
Link: updatedName
|
|
297
|
+
? {
|
|
298
|
+
Name: updatedName.encryptedName,
|
|
299
|
+
Hash: updatedName.hash,
|
|
300
|
+
OriginalHash: updatedName.originalHash,
|
|
301
|
+
NameSignatureEmail: updatedName.nameSignatureEmail,
|
|
302
|
+
}
|
|
303
|
+
: null,
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async deleteAlbum(albumNodeUid: string, options: { force?: boolean } = {}): Promise<void> {
|
|
309
|
+
const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid);
|
|
310
|
+
try {
|
|
311
|
+
await this.apiService.delete(
|
|
312
|
+
`drive/photos/volumes/${volumeId}/albums/${linkId}?DeleteAlbumPhotos=${options.force ? 1 : 0}`,
|
|
313
|
+
);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error instanceof APICodeError && error.code === ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE) {
|
|
316
|
+
throw new ValidationError(c('Error').t`Album contains photos not in timeline`);
|
|
317
|
+
}
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async *removePhotosFromAlbum(
|
|
323
|
+
albumNodeUid: string,
|
|
324
|
+
photoNodeUids: string[],
|
|
325
|
+
signal?: AbortSignal,
|
|
326
|
+
): AsyncGenerator<NodeResult> {
|
|
327
|
+
const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid);
|
|
328
|
+
|
|
329
|
+
const batchSize = 50;
|
|
330
|
+
|
|
331
|
+
for (const photoNodeUidsBatch of batch(photoNodeUids, batchSize)) {
|
|
332
|
+
const linkIds = photoNodeUidsBatch.map((nodeUid) => splitNodeUid(nodeUid).nodeId);
|
|
333
|
+
|
|
334
|
+
let errorMessage: string | undefined;
|
|
335
|
+
try {
|
|
336
|
+
await this.apiService.post<PostRemovePhotosFromAlbumRequest, PostRemovePhotosFromAlbumResponse>(
|
|
337
|
+
`drive/photos/volumes/${volumeId}/albums/${albumLinkId}/remove-multiple`,
|
|
338
|
+
{
|
|
339
|
+
LinkIDs: linkIds,
|
|
340
|
+
},
|
|
341
|
+
signal,
|
|
342
|
+
);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
errorMessage = error instanceof Error ? error.message : c('Error').t`Unknown error`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// The API does not return individual results for each photo.
|
|
348
|
+
for (const uid of photoNodeUidsBatch) {
|
|
349
|
+
if (errorMessage) {
|
|
350
|
+
yield { uid, ok: false, error: errorMessage };
|
|
351
|
+
} else {
|
|
352
|
+
yield { uid, ok: true };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
191
357
|
}
|
|
@@ -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,10 +32,8 @@ 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
|
-
export type { DecryptedPhotoNode } from './interface';
|
|
36
|
+
export type { DecryptedPhotoNode, TimelineItem, AlbumItem, PhotoTag } from './interface';
|
|
36
37
|
|
|
37
38
|
// Only photos and albums can be shared in photos volume.
|
|
38
39
|
export const PHOTOS_SHARE_TARGET_TYPES = [ShareTargetType.Photo, ShareTargetType.Album];
|
|
@@ -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,
|
|
@@ -42,3 +42,27 @@ export type EcnryptedPhotoAttributes = Omit<PhotoAttributes, 'albums'> & {
|
|
|
42
42
|
contentHash?: string;
|
|
43
43
|
})[];
|
|
44
44
|
};
|
|
45
|
+
|
|
46
|
+
export type TimelineItem = {
|
|
47
|
+
nodeUid: string;
|
|
48
|
+
captureTime: Date;
|
|
49
|
+
tags: PhotoTag[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type AlbumItem = {
|
|
53
|
+
nodeUid: string;
|
|
54
|
+
captureTime: Date;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export enum PhotoTag {
|
|
58
|
+
Favorites = 0,
|
|
59
|
+
Screenshots = 1,
|
|
60
|
+
Videos = 2,
|
|
61
|
+
LivePhotos = 3,
|
|
62
|
+
MotionPhotos = 4,
|
|
63
|
+
Selfies = 5,
|
|
64
|
+
Portraits = 6,
|
|
65
|
+
Bursts = 7,
|
|
66
|
+
Panoramas = 8,
|
|
67
|
+
Raw = 9,
|
|
68
|
+
}
|
|
@@ -2,6 +2,7 @@ import { DriveCrypto } from '../../crypto';
|
|
|
2
2
|
import { Logger } from '../../interface';
|
|
3
3
|
import { makeNodeUid } from '../uids';
|
|
4
4
|
import { PhotosAPIService } from './apiService';
|
|
5
|
+
import { TimelineItem } from './interface';
|
|
5
6
|
import { PhotosNodesAccess } from './nodes';
|
|
6
7
|
import { PhotoSharesManager } from './shares';
|
|
7
8
|
|
|
@@ -23,11 +24,7 @@ export class PhotosTimeline {
|
|
|
23
24
|
this.nodesService = nodesService;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
async *iterateTimeline(signal?: AbortSignal): AsyncGenerator<{
|
|
27
|
-
nodeUid: string;
|
|
28
|
-
captureTime: Date;
|
|
29
|
-
tags: number[];
|
|
30
|
-
}> {
|
|
27
|
+
async *iterateTimeline(signal?: AbortSignal): AsyncGenerator<TimelineItem> {
|
|
31
28
|
const { volumeId } = await this.photoShares.getRootIDs();
|
|
32
29
|
yield* this.apiService.iterateTimeline(volumeId, signal);
|
|
33
30
|
}
|
|
@@ -109,13 +109,14 @@ export class PhotoStreamUploader extends StreamUploader {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
async commitFile(thumbnails: Thumbnail[]) {
|
|
112
|
-
this.
|
|
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
|
|
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.
|
|
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
|
|
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
|
/**
|
|
@@ -38,6 +38,9 @@ import {
|
|
|
38
38
|
initPhotoSharesModule,
|
|
39
39
|
initPhotoUploadModule,
|
|
40
40
|
initPhotosNodesModule,
|
|
41
|
+
AlbumItem,
|
|
42
|
+
TimelineItem,
|
|
43
|
+
PhotoTag,
|
|
41
44
|
} from './internal/photos';
|
|
42
45
|
import { SDKEvents } from './internal/sdkEvents';
|
|
43
46
|
import { initSharesModule } from './internal/shares';
|
|
@@ -117,7 +120,13 @@ export class ProtonDrivePhotosClient {
|
|
|
117
120
|
this.photoShares,
|
|
118
121
|
fullConfig.clientUid,
|
|
119
122
|
);
|
|
120
|
-
this.photos = initPhotosModule(
|
|
123
|
+
this.photos = initPhotosModule(
|
|
124
|
+
telemetry,
|
|
125
|
+
apiService,
|
|
126
|
+
cryptoModule,
|
|
127
|
+
this.photoShares,
|
|
128
|
+
this.nodes.access,
|
|
129
|
+
);
|
|
121
130
|
this.sharing = initSharingModule(
|
|
122
131
|
telemetry,
|
|
123
132
|
apiService,
|
|
@@ -218,12 +227,7 @@ export class ProtonDrivePhotosClient {
|
|
|
218
227
|
* The output is sorted by the capture time, starting from the
|
|
219
228
|
* the most recent photos.
|
|
220
229
|
*/
|
|
221
|
-
async *iterateTimeline(signal?: AbortSignal): AsyncGenerator<{
|
|
222
|
-
nodeUid: string;
|
|
223
|
-
captureTime: Date;
|
|
224
|
-
tags: number[];
|
|
225
|
-
}> {
|
|
226
|
-
// TODO: expose better type
|
|
230
|
+
async *iterateTimeline(signal?: AbortSignal): AsyncGenerator<TimelineItem> {
|
|
227
231
|
yield* this.photos.timeline.iterateTimeline(signal);
|
|
228
232
|
}
|
|
229
233
|
|
|
@@ -450,8 +454,7 @@ export class ProtonDrivePhotosClient {
|
|
|
450
454
|
metadata: UploadMetadata & {
|
|
451
455
|
captureTime?: Date;
|
|
452
456
|
mainPhotoLinkID?: string;
|
|
453
|
-
|
|
454
|
-
tags?: (0 | 3 | 1 | 2 | 7 | 4 | 5 | 6 | 8 | 9)[];
|
|
457
|
+
tags?: PhotoTag[];
|
|
455
458
|
},
|
|
456
459
|
signal?: AbortSignal,
|
|
457
460
|
): Promise<FileUploader> {
|
|
@@ -507,6 +510,63 @@ export class ProtonDrivePhotosClient {
|
|
|
507
510
|
return this.photos.timeline.findPhotoDuplicates(name, generateSha1, signal);
|
|
508
511
|
}
|
|
509
512
|
|
|
513
|
+
/**
|
|
514
|
+
* Creates a new album with the given name.
|
|
515
|
+
*
|
|
516
|
+
* @param name - The name for the new album.
|
|
517
|
+
* @returns The created album node.
|
|
518
|
+
*/
|
|
519
|
+
async createAlbum(name: string): Promise<MaybePhotoNode> {
|
|
520
|
+
this.logger.info('Creating album');
|
|
521
|
+
return convertInternalPhotoNodePromise(this.photos.albums.createAlbum(name));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Updates an existing album.
|
|
526
|
+
*
|
|
527
|
+
* Updates can include a new name and/or a cover photo.
|
|
528
|
+
*
|
|
529
|
+
* @param nodeUid - The UID of the album to edit.
|
|
530
|
+
* @param updates - The updates to apply.
|
|
531
|
+
* @returns The updated album node.
|
|
532
|
+
*/
|
|
533
|
+
async updateAlbum(
|
|
534
|
+
nodeUid: NodeOrUid,
|
|
535
|
+
updates: {
|
|
536
|
+
name?: string;
|
|
537
|
+
coverPhotoNodeUid?: NodeOrUid;
|
|
538
|
+
},
|
|
539
|
+
): Promise<MaybePhotoNode> {
|
|
540
|
+
this.logger.info(`Updating album ${getUid(nodeUid)}`);
|
|
541
|
+
const coverPhotoNodeUid = updates.coverPhotoNodeUid ? getUid(updates.coverPhotoNodeUid) : undefined;
|
|
542
|
+
return convertInternalPhotoNodePromise(
|
|
543
|
+
this.photos.albums.updateAlbum(getUid(nodeUid), {
|
|
544
|
+
name: updates.name,
|
|
545
|
+
coverPhotoNodeUid,
|
|
546
|
+
}),
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Deletes an album.
|
|
552
|
+
*
|
|
553
|
+
* Photos in the timeline will not be deleted. If the album has photos
|
|
554
|
+
* that are not in the timeline (uploaded by another user), the method
|
|
555
|
+
* will throw an error. The photos must be moved to the timeline, or
|
|
556
|
+
* the album must be deleted with `force` option that deletes the photos
|
|
557
|
+
* not in the timeline as well.
|
|
558
|
+
*
|
|
559
|
+
* This operation is irreversible. Both the album and the photos will be
|
|
560
|
+
* permanently deleted, skipping the trash.
|
|
561
|
+
*
|
|
562
|
+
* @param nodeUid - The UID of the album to delete.
|
|
563
|
+
* @param force - Whether to force the deletion.
|
|
564
|
+
*/
|
|
565
|
+
async deleteAlbum(nodeUid: NodeOrUid, options: { force?: boolean } = {}): Promise<void> {
|
|
566
|
+
this.logger.info(`Deleting album ${getUid(nodeUid)}`);
|
|
567
|
+
await this.photos.albums.deleteAlbum(getUid(nodeUid), options);
|
|
568
|
+
}
|
|
569
|
+
|
|
510
570
|
/**
|
|
511
571
|
* Iterates the albums.
|
|
512
572
|
*
|
|
@@ -517,4 +577,39 @@ export class ProtonDrivePhotosClient {
|
|
|
517
577
|
// TODO: expose album type
|
|
518
578
|
yield * convertInternalPhotoNodeIterator(this.photos.albums.iterateAlbums(signal));
|
|
519
579
|
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Iterates the photo placeholders of the given album.
|
|
583
|
+
*
|
|
584
|
+
* The output is sorted by the capture time, starting from the
|
|
585
|
+
* the most recent photos.
|
|
586
|
+
*
|
|
587
|
+
* @param albumNodeUid - The UID of the album.
|
|
588
|
+
* @param signal - An optional abort the operation.
|
|
589
|
+
*/
|
|
590
|
+
async *iterateAlbum(albumNodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator<AlbumItem> {
|
|
591
|
+
this.logger.info(`Iterating photos of album ${getUid(albumNodeUid)}`);
|
|
592
|
+
yield* this.photos.albums.iterateAlbum(getUid(albumNodeUid), signal);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Removes photos from an album.
|
|
597
|
+
*
|
|
598
|
+
* Photos are not deleted, they are just removed from the album.
|
|
599
|
+
* If a photo was added to the timeline by the user, it will remain
|
|
600
|
+
* in the timeline after being removed from the album.
|
|
601
|
+
*
|
|
602
|
+
* @param albumNodeUid - The UID of the album to remove photos from.
|
|
603
|
+
* @param photoNodeUids - The UIDs of the photos to remove from the album.
|
|
604
|
+
* @param signal - An optional abort signal to cancel the operation.
|
|
605
|
+
* @returns An async generator of the removed photo results.
|
|
606
|
+
*/
|
|
607
|
+
async *removePhotosFromAlbum(
|
|
608
|
+
albumNodeUid: NodeOrUid,
|
|
609
|
+
photoNodeUids: NodeOrUid[],
|
|
610
|
+
signal?: AbortSignal,
|
|
611
|
+
): AsyncGenerator<NodeResult> {
|
|
612
|
+
this.logger.info(`Removing ${photoNodeUids.length} photos from album ${getUid(albumNodeUid)}`);
|
|
613
|
+
yield* this.photos.albums.removePhotos(getUid(albumNodeUid), getUids(photoNodeUids), signal);
|
|
614
|
+
}
|
|
520
615
|
}
|