@protontech/drive-sdk 0.14.7 → 0.14.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/nodes.d.ts +1 -0
- package/dist/interface/nodes.js.map +1 -1
- package/dist/internal/apiService/driveTypes.d.ts +294 -261
- package/dist/internal/nodes/apiService.js +1 -0
- package/dist/internal/nodes/apiService.js.map +1 -1
- package/dist/internal/nodes/cache.test.js +1 -0
- package/dist/internal/nodes/cache.test.js.map +1 -1
- package/dist/internal/nodes/cryptoService.js +1 -0
- package/dist/internal/nodes/cryptoService.js.map +1 -1
- package/dist/internal/nodes/interface.d.ts +2 -0
- package/dist/internal/nodes/nodesAccess.js +4 -0
- package/dist/internal/nodes/nodesAccess.js.map +1 -1
- package/dist/internal/nodes/nodesRevisions.js +8 -0
- package/dist/internal/nodes/nodesRevisions.js.map +1 -1
- package/dist/internal/photos/albumsManager.js +9 -0
- package/dist/internal/photos/albumsManager.js.map +1 -1
- package/dist/internal/photos/albumsManager.test.js +37 -0
- package/dist/internal/photos/albumsManager.test.js.map +1 -1
- package/dist/internal/photos/apiService.js +4 -1
- package/dist/internal/photos/apiService.js.map +1 -1
- package/dist/internal/photos/nodes.d.ts +15 -4
- package/dist/internal/photos/nodes.js +26 -0
- package/dist/internal/photos/nodes.js.map +1 -1
- package/dist/internal/photos/nodes.test.js +28 -0
- package/dist/internal/photos/nodes.test.js.map +1 -1
- package/dist/internal/photos/upload.d.ts +4 -1
- package/dist/internal/photos/upload.js +8 -4
- package/dist/internal/photos/upload.js.map +1 -1
- package/dist/internal/upload/apiService.d.ts +3 -0
- package/dist/internal/upload/apiService.js +3 -0
- package/dist/internal/upload/apiService.js.map +1 -1
- package/dist/internal/upload/manager.d.ts +4 -1
- package/dist/internal/upload/manager.js +6 -2
- package/dist/internal/upload/manager.js.map +1 -1
- package/dist/internal/upload/smallFileUploader.d.ts +1 -0
- package/dist/internal/upload/smallFileUploader.js +1 -0
- package/dist/internal/upload/smallFileUploader.js.map +1 -1
- package/dist/internal/upload/streamUploader.d.ts +3 -2
- package/dist/internal/upload/streamUploader.js +40 -22
- package/dist/internal/upload/streamUploader.js.map +1 -1
- package/dist/internal/upload/streamUploader.test.js +3 -1
- package/dist/internal/upload/streamUploader.test.js.map +1 -1
- package/dist/protonDrivePhotosClient.d.ts +8 -0
- package/dist/protonDrivePhotosClient.js +11 -0
- package/dist/protonDrivePhotosClient.js.map +1 -1
- package/package.json +2 -1
- package/src/interface/nodes.ts +1 -0
- package/src/internal/apiService/driveTypes.ts +294 -261
- package/src/internal/nodes/apiService.ts +1 -0
- package/src/internal/nodes/cache.test.ts +1 -0
- package/src/internal/nodes/cryptoService.ts +1 -0
- package/src/internal/nodes/interface.ts +2 -0
- package/src/internal/nodes/nodesAccess.ts +4 -0
- package/src/internal/nodes/nodesRevisions.ts +8 -0
- package/src/internal/photos/albumsManager.test.ts +41 -0
- package/src/internal/photos/albumsManager.ts +9 -0
- package/src/internal/photos/apiService.ts +6 -1
- package/src/internal/photos/nodes.test.ts +42 -0
- package/src/internal/photos/nodes.ts +29 -0
- package/src/internal/photos/upload.ts +13 -2
- package/src/internal/upload/apiService.ts +6 -0
- package/src/internal/upload/manager.ts +7 -1
- package/src/internal/upload/smallFileUploader.ts +3 -0
- package/src/internal/upload/streamUploader.test.ts +3 -0
- package/src/internal/upload/streamUploader.ts +50 -23
- package/src/protonDrivePhotosClient.ts +15 -0
|
@@ -851,6 +851,7 @@ function transformRevisionResponse(
|
|
|
851
851
|
signatureEmail: revision.SignatureEmail || undefined,
|
|
852
852
|
armoredExtendedAttributes: revision.XAttr || undefined,
|
|
853
853
|
thumbnails: revision.Thumbnails?.map((thumbnail) => transformThumbnail(volumeId, nodeId, thumbnail)) || [],
|
|
854
|
+
sha1Verified: revision.ChecksumVerified,
|
|
854
855
|
};
|
|
855
856
|
}
|
|
856
857
|
|
|
@@ -181,11 +181,13 @@ export type Thumbnail = {
|
|
|
181
181
|
export interface EncryptedRevision extends BaseRevision {
|
|
182
182
|
signatureEmail?: string;
|
|
183
183
|
armoredExtendedAttributes?: string;
|
|
184
|
+
sha1Verified?: boolean;
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
export interface DecryptedUnparsedRevision extends BaseRevision {
|
|
187
188
|
contentAuthor: Author;
|
|
188
189
|
extendedAttributes?: string;
|
|
190
|
+
sha1Verified?: boolean;
|
|
189
191
|
}
|
|
190
192
|
|
|
191
193
|
export interface DecryptedRevision extends Revision {
|
|
@@ -549,6 +549,10 @@ export function parseNode(logger: Logger, unparsedNode: DecryptedUnparsedNode):
|
|
|
549
549
|
contentAuthor: unparsedNode.activeRevision.value.contentAuthor,
|
|
550
550
|
thumbnails: unparsedNode.activeRevision.value.thumbnails,
|
|
551
551
|
...extendedAttributes,
|
|
552
|
+
claimedDigests: {
|
|
553
|
+
...extendedAttributes?.claimedDigests,
|
|
554
|
+
sha1Verified: unparsedNode.activeRevision.value.sha1Verified || false,
|
|
555
|
+
},
|
|
552
556
|
}),
|
|
553
557
|
folder: undefined,
|
|
554
558
|
treeEventScopeId,
|
|
@@ -36,6 +36,10 @@ export class NodesRevisons {
|
|
|
36
36
|
return {
|
|
37
37
|
...revision,
|
|
38
38
|
...extendedAttributes,
|
|
39
|
+
claimedDigests: {
|
|
40
|
+
...extendedAttributes?.claimedDigests,
|
|
41
|
+
sha1Verified: revision.sha1Verified || false,
|
|
42
|
+
},
|
|
39
43
|
};
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -53,6 +57,10 @@ export class NodesRevisons {
|
|
|
53
57
|
yield {
|
|
54
58
|
...revision,
|
|
55
59
|
...extendedAttributes,
|
|
60
|
+
claimedDigests: {
|
|
61
|
+
...extendedAttributes?.claimedDigests,
|
|
62
|
+
sha1Verified: revision.sha1Verified || false,
|
|
63
|
+
},
|
|
56
64
|
};
|
|
57
65
|
}
|
|
58
66
|
}
|
|
@@ -293,6 +293,47 @@ describe('Albums', () => {
|
|
|
293
293
|
});
|
|
294
294
|
});
|
|
295
295
|
|
|
296
|
+
describe('iterateAlbumUids', () => {
|
|
297
|
+
it('yields album uids and patches metadata into cache', async () => {
|
|
298
|
+
const album1 = {
|
|
299
|
+
albumUid: 'volumeId~album1',
|
|
300
|
+
photoCount: 3,
|
|
301
|
+
coverNodeUid: 'volumeId~cover1',
|
|
302
|
+
lastActivityTime: new Date('2024-01-01T00:00:00.000Z'),
|
|
303
|
+
};
|
|
304
|
+
const album2 = {
|
|
305
|
+
albumUid: 'volumeId~album2',
|
|
306
|
+
photoCount: 0,
|
|
307
|
+
coverNodeUid: undefined,
|
|
308
|
+
lastActivityTime: new Date('2024-02-01T00:00:00.000Z'),
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
apiService.iterateAlbums = jest.fn().mockImplementation(async function* () {
|
|
312
|
+
yield album1;
|
|
313
|
+
yield album2;
|
|
314
|
+
});
|
|
315
|
+
nodesService.updateAlbumMetadataCache = jest.fn().mockResolvedValue(undefined);
|
|
316
|
+
|
|
317
|
+
const uids: string[] = [];
|
|
318
|
+
for await (const uid of albums.iterateAlbumUids()) {
|
|
319
|
+
uids.push(uid);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
expect(uids).toEqual(['volumeId~album1', 'volumeId~album2']);
|
|
323
|
+
expect(nodesService.updateAlbumMetadataCache).toHaveBeenCalledTimes(2);
|
|
324
|
+
expect(nodesService.updateAlbumMetadataCache).toHaveBeenCalledWith('volumeId~album1', {
|
|
325
|
+
photoCount: 3,
|
|
326
|
+
coverNodeUid: 'volumeId~cover1',
|
|
327
|
+
lastActivityTime: album1.lastActivityTime,
|
|
328
|
+
});
|
|
329
|
+
expect(nodesService.updateAlbumMetadataCache).toHaveBeenCalledWith('volumeId~album2', {
|
|
330
|
+
photoCount: 0,
|
|
331
|
+
coverNodeUid: undefined,
|
|
332
|
+
lastActivityTime: album2.lastActivityTime,
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
296
337
|
describe('removePhotos', () => {
|
|
297
338
|
it('notifies nodes service only for successfully removed photos', async () => {
|
|
298
339
|
apiService.removePhotosFromAlbum = jest.fn().mockImplementation(async function* () {
|
|
@@ -53,6 +53,15 @@ export class AlbumsManager {
|
|
|
53
53
|
const { volumeId } = await this.photoShares.getRootIDs();
|
|
54
54
|
|
|
55
55
|
for await (const album of this.apiService.iterateAlbums(volumeId, signal)) {
|
|
56
|
+
// Patch fresh album metadata into the node cache so that the subsequent
|
|
57
|
+
// iterateNodes call returns up-to-date photoCount/coverNodeUid without
|
|
58
|
+
// an extra API round-trip. The fresh data comes from the /albums endpoint
|
|
59
|
+
// which always reflects the current state.
|
|
60
|
+
void this.nodesService.updateAlbumMetadataCache(album.albumUid, {
|
|
61
|
+
photoCount: album.photoCount,
|
|
62
|
+
coverNodeUid: album.coverNodeUid,
|
|
63
|
+
lastActivityTime: album.lastActivityTime,
|
|
64
|
+
});
|
|
56
65
|
yield album.albumUid;
|
|
57
66
|
}
|
|
58
67
|
}
|
|
@@ -264,7 +264,12 @@ export class PhotosAPIService {
|
|
|
264
264
|
);
|
|
265
265
|
|
|
266
266
|
return response.DuplicateHashes.map((duplicate) => {
|
|
267
|
-
if (
|
|
267
|
+
if (
|
|
268
|
+
!duplicate.Hash ||
|
|
269
|
+
!duplicate.ContentHash ||
|
|
270
|
+
!duplicate.LinkID ||
|
|
271
|
+
duplicate.LinkState !== 1 /* Active */
|
|
272
|
+
) {
|
|
268
273
|
return undefined;
|
|
269
274
|
}
|
|
270
275
|
return {
|
|
@@ -3,6 +3,7 @@ import { NodeType, MemberRole } from '../../interface';
|
|
|
3
3
|
import { getMockLogger } from '../../tests/logger';
|
|
4
4
|
import { getMockTelemetry } from '../../tests/telemetry';
|
|
5
5
|
import { DriveAPIService } from '../apiService';
|
|
6
|
+
import { DecryptedPhotoNode } from './interface';
|
|
6
7
|
import { PhotosNodesAPIService, PhotosNodesCache, PhotosNodesAccess, PhotosNodesCryptoService } from './nodes';
|
|
7
8
|
|
|
8
9
|
function generateAPINode() {
|
|
@@ -266,6 +267,47 @@ describe('PhotosNodesAccess', () => {
|
|
|
266
267
|
});
|
|
267
268
|
});
|
|
268
269
|
|
|
270
|
+
describe('updateAlbumMetadataCache', () => {
|
|
271
|
+
let access: PhotosNodesAccess;
|
|
272
|
+
let mockCache: { getNode: jest.Mock; setNode: jest.Mock };
|
|
273
|
+
|
|
274
|
+
beforeEach(() => {
|
|
275
|
+
mockCache = { getNode: jest.fn(), setNode: jest.fn() };
|
|
276
|
+
access = new PhotosNodesAccess(
|
|
277
|
+
getMockTelemetry(),
|
|
278
|
+
// @ts-expect-error Mocking for testing purposes
|
|
279
|
+
{},
|
|
280
|
+
mockCache,
|
|
281
|
+
{ getNodeKeys: jest.fn().mockRejectedValue(new Error()) },
|
|
282
|
+
{},
|
|
283
|
+
{},
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('updates album metadata in cache', async () => {
|
|
288
|
+
const existing = { uid: 'v~album1', type: NodeType.Album, album: { photoCount: 1, coverPhotoNodeUid: 'v~old', lastActivityTime: new Date('2024-01-01') } } as DecryptedPhotoNode;
|
|
289
|
+
mockCache.getNode.mockResolvedValue(existing);
|
|
290
|
+
|
|
291
|
+
await access.updateAlbumMetadataCache('v~album1', { photoCount: 5, coverNodeUid: 'v~new', lastActivityTime: new Date('2024-06-01') });
|
|
292
|
+
|
|
293
|
+
expect(mockCache.setNode).toHaveBeenCalledWith(expect.objectContaining({
|
|
294
|
+
album: { photoCount: 5, coverPhotoNodeUid: 'v~new', lastActivityTime: new Date('2024-06-01') },
|
|
295
|
+
}));
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('does nothing when node is not in cache', async () => {
|
|
299
|
+
mockCache.getNode.mockRejectedValue(new Error('Entity not found'));
|
|
300
|
+
await expect(access.updateAlbumMetadataCache('v~missing', { photoCount: 3, coverNodeUid: undefined, lastActivityTime: new Date() })).resolves.toBeUndefined();
|
|
301
|
+
expect(mockCache.setNode).not.toHaveBeenCalled();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('does nothing when cached node has no album field', async () => {
|
|
305
|
+
mockCache.getNode.mockResolvedValue({ uid: 'v~folder1', type: NodeType.Folder } as DecryptedPhotoNode);
|
|
306
|
+
await access.updateAlbumMetadataCache('v~folder1', { photoCount: 2, coverNodeUid: undefined, lastActivityTime: new Date() });
|
|
307
|
+
expect(mockCache.setNode).not.toHaveBeenCalled();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
269
311
|
describe('parseNode', () => {
|
|
270
312
|
it('should keep photo type and add photo object', async () => {
|
|
271
313
|
const telemetry = getMockTelemetry();
|
|
@@ -237,6 +237,35 @@ export class PhotosNodesAccess extends NodesAccessBase<EncryptedPhotoNode, Decry
|
|
|
237
237
|
|
|
238
238
|
return parseNodeBase(this.logger, unparsedNode);
|
|
239
239
|
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Update album metadata fields in the cache without invalidating the node.
|
|
243
|
+
* Used by iterateAlbumUids to patch fresh API data (photoCount, coverNodeUid,
|
|
244
|
+
* lastActivityTime) into already-cached nodes so iterateNodes doesn't re-fetch
|
|
245
|
+
* the full node just to get up-to-date album attributes.
|
|
246
|
+
*/
|
|
247
|
+
async updateAlbumMetadataCache(
|
|
248
|
+
albumUid: string,
|
|
249
|
+
metadata: { photoCount: number; coverNodeUid?: string; lastActivityTime: Date },
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
try {
|
|
252
|
+
const cached = await this.cache.getNode(albumUid);
|
|
253
|
+
if (!cached?.album) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
await this.cache.setNode({
|
|
257
|
+
...cached,
|
|
258
|
+
album: {
|
|
259
|
+
...cached.album,
|
|
260
|
+
photoCount: metadata.photoCount,
|
|
261
|
+
coverPhotoNodeUid: metadata.coverNodeUid,
|
|
262
|
+
lastActivityTime: metadata.lastActivityTime,
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
} catch {
|
|
266
|
+
// Cache miss is fine — node will be fetched fresh by iterateNodes anyway.
|
|
267
|
+
}
|
|
268
|
+
}
|
|
240
269
|
}
|
|
241
270
|
|
|
242
271
|
export class PhotosNodesCryptoService extends NodesCryptoService {
|
|
@@ -128,7 +128,7 @@ export class PhotoStreamUploader extends StreamUploader {
|
|
|
128
128
|
|
|
129
129
|
async commitFile(thumbnails: Thumbnail[]) {
|
|
130
130
|
const digests = this.digests.digests();
|
|
131
|
-
this.verifyIntegrity(thumbnails, digests);
|
|
131
|
+
const integrityInfo = this.verifyIntegrity(thumbnails, digests);
|
|
132
132
|
|
|
133
133
|
const extendedAttributes = {
|
|
134
134
|
modificationTime: this.metadata.modificationTime,
|
|
@@ -142,6 +142,7 @@ export class PhotoStreamUploader extends StreamUploader {
|
|
|
142
142
|
await this.getManifest(),
|
|
143
143
|
extendedAttributes,
|
|
144
144
|
this.photoMetadata,
|
|
145
|
+
integrityInfo,
|
|
145
146
|
);
|
|
146
147
|
}
|
|
147
148
|
}
|
|
@@ -174,6 +175,7 @@ export class PhotoUploadManager extends UploadManager {
|
|
|
174
175
|
};
|
|
175
176
|
},
|
|
176
177
|
uploadMetadata: PhotoUploadMetadata,
|
|
178
|
+
integrityInfo: { checksumVerified: boolean },
|
|
177
179
|
): Promise<void> {
|
|
178
180
|
if (!nodeRevisionDraft.parentNodeKeys) {
|
|
179
181
|
throw new Error('Parent node keys are required for photo upload');
|
|
@@ -201,7 +203,14 @@ export class PhotoUploadManager extends UploadManager {
|
|
|
201
203
|
mainPhotoNodeUid: uploadMetadata.mainPhotoNodeUid,
|
|
202
204
|
tags: uploadMetadata.tags,
|
|
203
205
|
};
|
|
204
|
-
await this.photoApiService.commitDraftPhoto(
|
|
206
|
+
await this.photoApiService.commitDraftPhoto(
|
|
207
|
+
nodeRevisionDraft.nodeRevisionUid,
|
|
208
|
+
{
|
|
209
|
+
...nodeCommitCrypto,
|
|
210
|
+
...integrityInfo,
|
|
211
|
+
},
|
|
212
|
+
photo,
|
|
213
|
+
);
|
|
205
214
|
await this.notifyNodeUploaded(nodeRevisionDraft);
|
|
206
215
|
}
|
|
207
216
|
}
|
|
@@ -232,6 +241,7 @@ export class PhotoUploadAPIService extends UploadAPIService {
|
|
|
232
241
|
armoredManifestSignature: string;
|
|
233
242
|
signatureEmail: string | AnonymousUser;
|
|
234
243
|
armoredExtendedAttributes?: string;
|
|
244
|
+
checksumVerified?: boolean;
|
|
235
245
|
},
|
|
236
246
|
photo: {
|
|
237
247
|
contentHash: string;
|
|
@@ -257,6 +267,7 @@ export class PhotoUploadAPIService extends UploadAPIService {
|
|
|
257
267
|
ManifestSignature: options.armoredManifestSignature,
|
|
258
268
|
SignatureAddress: options.signatureEmail,
|
|
259
269
|
XAttr: options.armoredExtendedAttributes || null,
|
|
270
|
+
ChecksumVerified: options.checksumVerified || false,
|
|
260
271
|
Photo: {
|
|
261
272
|
ContentHash: photo.contentHash,
|
|
262
273
|
CaptureTime: photo.captureTime ? Math.floor(photo.captureTime?.getTime() / 1000) : 0,
|
|
@@ -239,6 +239,7 @@ export class UploadAPIService {
|
|
|
239
239
|
armoredManifestSignature: string;
|
|
240
240
|
signatureEmail: string | AnonymousUser;
|
|
241
241
|
armoredExtendedAttributes: string;
|
|
242
|
+
checksumVerified?: boolean;
|
|
242
243
|
},
|
|
243
244
|
): Promise<void> {
|
|
244
245
|
const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid);
|
|
@@ -250,6 +251,7 @@ export class UploadAPIService {
|
|
|
250
251
|
ManifestSignature: options.armoredManifestSignature,
|
|
251
252
|
SignatureAddress: options.signatureEmail,
|
|
252
253
|
XAttr: options.armoredExtendedAttributes,
|
|
254
|
+
ChecksumVerified: options.checksumVerified || false,
|
|
253
255
|
Photo: null, // Only used for photos in the Photo volume.
|
|
254
256
|
});
|
|
255
257
|
}
|
|
@@ -322,6 +324,7 @@ export class UploadAPIService {
|
|
|
322
324
|
},
|
|
323
325
|
content: {
|
|
324
326
|
armoredManifestSignature: string;
|
|
327
|
+
checksumVerified?: boolean;
|
|
325
328
|
block:
|
|
326
329
|
| {
|
|
327
330
|
encryptedData: Uint8Array<ArrayBuffer>;
|
|
@@ -355,6 +358,7 @@ export class UploadAPIService {
|
|
|
355
358
|
? uint8ArrayToBase64String(content.block.verificationToken)
|
|
356
359
|
: null,
|
|
357
360
|
XAttr: metadata.armoredExtendedAttributes,
|
|
361
|
+
ChecksumVerified: content.checksumVerified || false,
|
|
358
362
|
Photo: null, // TODO
|
|
359
363
|
};
|
|
360
364
|
|
|
@@ -395,6 +399,7 @@ export class UploadAPIService {
|
|
|
395
399
|
},
|
|
396
400
|
content: {
|
|
397
401
|
armoredManifestSignature: string;
|
|
402
|
+
checksumVerified?: boolean;
|
|
398
403
|
block:
|
|
399
404
|
| {
|
|
400
405
|
encryptedData: Uint8Array<ArrayBuffer>;
|
|
@@ -421,6 +426,7 @@ export class UploadAPIService {
|
|
|
421
426
|
? uint8ArrayToBase64String(content.block.verificationToken)
|
|
422
427
|
: null,
|
|
423
428
|
XAttr: metadata.armoredExtendedAttributes,
|
|
429
|
+
ChecksumVerified: content.checksumVerified || false,
|
|
424
430
|
};
|
|
425
431
|
|
|
426
432
|
const formData = new FormData();
|
|
@@ -154,6 +154,7 @@ export class UploadManager {
|
|
|
154
154
|
commitPayload: {
|
|
155
155
|
armoredManifestSignature: string;
|
|
156
156
|
armoredExtendedAttributes: string;
|
|
157
|
+
checksumVerified?: boolean;
|
|
157
158
|
},
|
|
158
159
|
encryptedBlock:
|
|
159
160
|
| {
|
|
@@ -182,6 +183,7 @@ export class UploadManager {
|
|
|
182
183
|
},
|
|
183
184
|
{
|
|
184
185
|
armoredManifestSignature: commitPayload.armoredManifestSignature,
|
|
186
|
+
checksumVerified: commitPayload.checksumVerified,
|
|
185
187
|
block: encryptedBlock
|
|
186
188
|
? {
|
|
187
189
|
encryptedData: encryptedBlock.encryptedData,
|
|
@@ -387,6 +389,7 @@ export class UploadManager {
|
|
|
387
389
|
};
|
|
388
390
|
},
|
|
389
391
|
additionalExtendedAttributes?: object,
|
|
392
|
+
integrityInfo?: { checksumVerified: boolean },
|
|
390
393
|
): Promise<void> {
|
|
391
394
|
const generatedExtendedAttributes = generateFileExtendedAttributes(
|
|
392
395
|
extendedAttributes,
|
|
@@ -398,7 +401,10 @@ export class UploadManager {
|
|
|
398
401
|
generatedExtendedAttributes,
|
|
399
402
|
);
|
|
400
403
|
try {
|
|
401
|
-
await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid,
|
|
404
|
+
await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid, {
|
|
405
|
+
...nodeCommitCrypto,
|
|
406
|
+
...integrityInfo,
|
|
407
|
+
});
|
|
402
408
|
} catch (error: unknown) {
|
|
403
409
|
// Commit might be sent but due to network error no response is
|
|
404
410
|
// received. In this case, API service automatically retries the
|
|
@@ -77,6 +77,7 @@ abstract class SmallUploader {
|
|
|
77
77
|
commitPayload: {
|
|
78
78
|
armoredManifestSignature: string;
|
|
79
79
|
armoredExtendedAttributes: string;
|
|
80
|
+
checksumVerified?: boolean;
|
|
80
81
|
};
|
|
81
82
|
encryptedBlock:
|
|
82
83
|
| {
|
|
@@ -253,6 +254,7 @@ abstract class SmallUploader {
|
|
|
253
254
|
): Promise<{
|
|
254
255
|
armoredManifestSignature: string;
|
|
255
256
|
armoredExtendedAttributes: string;
|
|
257
|
+
checksumVerified?: boolean;
|
|
256
258
|
}> {
|
|
257
259
|
this.logger.debug(`Preparing commit payload`);
|
|
258
260
|
|
|
@@ -269,6 +271,7 @@ abstract class SmallUploader {
|
|
|
269
271
|
return {
|
|
270
272
|
armoredManifestSignature: commitCrypto.armoredManifestSignature,
|
|
271
273
|
armoredExtendedAttributes: commitCrypto.armoredExtendedAttributes,
|
|
274
|
+
checksumVerified: !!(this.metadata.expectedSha1 && contentSha1 === this.metadata.expectedSha1),
|
|
272
275
|
};
|
|
273
276
|
}
|
|
274
277
|
}
|
|
@@ -172,6 +172,9 @@ describe('StreamUploader', () => {
|
|
|
172
172
|
},
|
|
173
173
|
},
|
|
174
174
|
metadata.additionalMetadata,
|
|
175
|
+
{
|
|
176
|
+
checksumVerified: !!metadata.expectedSha1,
|
|
177
|
+
},
|
|
175
178
|
);
|
|
176
179
|
expect(telemetry.uploadFinished).toHaveBeenCalledTimes(1);
|
|
177
180
|
expect(telemetry.uploadFinished).toHaveBeenCalledWith('revisionUid', metadata.expectedSize + thumbnailSize);
|
|
@@ -22,6 +22,39 @@ import { UploadManager } from './manager';
|
|
|
22
22
|
*/
|
|
23
23
|
export const FILE_CHUNK_SIZE = 4 * 1024 * 1024;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Creates an upload progress callback isolated from the caller's scope.
|
|
27
|
+
*
|
|
28
|
+
* When a closure is defined inside a function, the JS engine attaches it to
|
|
29
|
+
* the entire lexical environment of that function — all variables in scope,
|
|
30
|
+
* whether the closure uses them or not. This means an inline `onProgress`
|
|
31
|
+
* lambda defined inside `uploadBlockData` would keep `encryptedData` (the
|
|
32
|
+
* 4 MB buffer) alive for as long as the HTTP client holds the callback,
|
|
33
|
+
* even though the lambda never references `encryptedData`.
|
|
34
|
+
*
|
|
35
|
+
* By defining this factory at module level, the returned closures only see
|
|
36
|
+
* `reported` and `onProgress`. The encrypted data is invisible to them and
|
|
37
|
+
* can be garbage collected as soon as the upload completes.
|
|
38
|
+
*/
|
|
39
|
+
function createProgressCallback(onProgress?: (n: number) => void): {
|
|
40
|
+
callback: (uploadedBytes: number) => void;
|
|
41
|
+
rollback: () => void;
|
|
42
|
+
} {
|
|
43
|
+
let reported = 0;
|
|
44
|
+
return {
|
|
45
|
+
callback: (uploadedBytes: number) => {
|
|
46
|
+
reported += uploadedBytes;
|
|
47
|
+
onProgress?.(uploadedBytes);
|
|
48
|
+
},
|
|
49
|
+
rollback: () => {
|
|
50
|
+
if (reported !== 0) {
|
|
51
|
+
onProgress?.(-reported);
|
|
52
|
+
reported = 0;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
25
58
|
/**
|
|
26
59
|
* Maximum number of blocks that can be buffered before upload.
|
|
27
60
|
* This is to prevent using too much memory.
|
|
@@ -71,7 +104,6 @@ export class StreamUploader {
|
|
|
71
104
|
{
|
|
72
105
|
index?: number;
|
|
73
106
|
uploadPromise: Promise<void>;
|
|
74
|
-
encryptedBlock: EncryptedBlock | EncryptedThumbnail;
|
|
75
107
|
}
|
|
76
108
|
>();
|
|
77
109
|
protected uploadedThumbnails: ({ type: ThumbnailType } & EncryptedBlockMetadata)[] = [];
|
|
@@ -222,7 +254,7 @@ export class StreamUploader {
|
|
|
222
254
|
|
|
223
255
|
protected async commitFile(thumbnails: Thumbnail[]) {
|
|
224
256
|
const digests = this.digests.digests();
|
|
225
|
-
this.verifyIntegrity(thumbnails, digests);
|
|
257
|
+
const integrityInfo = this.verifyIntegrity(thumbnails, digests);
|
|
226
258
|
|
|
227
259
|
const extendedAttributes = {
|
|
228
260
|
modificationTime: this.metadata.modificationTime,
|
|
@@ -235,6 +267,7 @@ export class StreamUploader {
|
|
|
235
267
|
await this.getManifest(),
|
|
236
268
|
extendedAttributes,
|
|
237
269
|
this.metadata.additionalMetadata,
|
|
270
|
+
integrityInfo,
|
|
238
271
|
);
|
|
239
272
|
}
|
|
240
273
|
|
|
@@ -364,7 +397,6 @@ export class StreamUploader {
|
|
|
364
397
|
// Help the garbage collector to clean up the memory.
|
|
365
398
|
encryptedThumbnail = undefined;
|
|
366
399
|
}),
|
|
367
|
-
encryptedBlock: encryptedThumbnail,
|
|
368
400
|
});
|
|
369
401
|
}
|
|
370
402
|
|
|
@@ -385,7 +417,6 @@ export class StreamUploader {
|
|
|
385
417
|
// Help the garbage collector to clean up the memory.
|
|
386
418
|
encryptedBlock = undefined;
|
|
387
419
|
}),
|
|
388
|
-
encryptedBlock,
|
|
389
420
|
});
|
|
390
421
|
}
|
|
391
422
|
}
|
|
@@ -401,8 +432,8 @@ export class StreamUploader {
|
|
|
401
432
|
);
|
|
402
433
|
logger.info(`Upload started`);
|
|
403
434
|
|
|
404
|
-
let blockProgress = 0;
|
|
405
435
|
let attempt = 0;
|
|
436
|
+
const { callback: progressCallback, rollback: rollbackProgress } = createProgressCallback(onProgress);
|
|
406
437
|
|
|
407
438
|
while (true) {
|
|
408
439
|
attempt++;
|
|
@@ -412,10 +443,7 @@ export class StreamUploader {
|
|
|
412
443
|
uploadToken.bareUrl,
|
|
413
444
|
uploadToken.token,
|
|
414
445
|
encryptedThumbnail.encryptedData,
|
|
415
|
-
|
|
416
|
-
blockProgress += uploadedBytes;
|
|
417
|
-
onProgress?.(uploadedBytes);
|
|
418
|
-
},
|
|
446
|
+
progressCallback,
|
|
419
447
|
this.abortController.signal,
|
|
420
448
|
);
|
|
421
449
|
this.uploadedThumbnails.push({
|
|
@@ -431,10 +459,7 @@ export class StreamUploader {
|
|
|
431
459
|
throw error;
|
|
432
460
|
}
|
|
433
461
|
|
|
434
|
-
|
|
435
|
-
onProgress?.(-blockProgress);
|
|
436
|
-
blockProgress = 0;
|
|
437
|
-
}
|
|
462
|
+
rollbackProgress();
|
|
438
463
|
|
|
439
464
|
// Note: We don't handle token expiration for thumbnails, because
|
|
440
465
|
// the API requires the thumbnails to be requested with the first
|
|
@@ -468,8 +493,8 @@ export class StreamUploader {
|
|
|
468
493
|
const logger = new LoggerWithPrefix(this.logger, `block ${uploadToken.index}:${uploadToken.token}`);
|
|
469
494
|
logger.info(`Upload started`);
|
|
470
495
|
|
|
471
|
-
let blockProgress = 0;
|
|
472
496
|
let attempt = 0;
|
|
497
|
+
const { callback: progressCallback, rollback: rollbackProgress } = createProgressCallback(onProgress);
|
|
473
498
|
|
|
474
499
|
while (true) {
|
|
475
500
|
if (this.isUploadAborted) {
|
|
@@ -483,10 +508,7 @@ export class StreamUploader {
|
|
|
483
508
|
uploadToken.bareUrl,
|
|
484
509
|
uploadToken.token,
|
|
485
510
|
encryptedBlock.encryptedData,
|
|
486
|
-
|
|
487
|
-
blockProgress += uploadedBytes;
|
|
488
|
-
onProgress?.(uploadedBytes);
|
|
489
|
-
},
|
|
511
|
+
progressCallback,
|
|
490
512
|
this.abortController.signal,
|
|
491
513
|
);
|
|
492
514
|
this.uploadedBlocks.push({
|
|
@@ -502,10 +524,7 @@ export class StreamUploader {
|
|
|
502
524
|
throw error;
|
|
503
525
|
}
|
|
504
526
|
|
|
505
|
-
|
|
506
|
-
onProgress?.(-blockProgress);
|
|
507
|
-
blockProgress = 0;
|
|
508
|
-
}
|
|
527
|
+
rollbackProgress();
|
|
509
528
|
|
|
510
529
|
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
511
530
|
logger.warn(`Upload timeout, limiting upload capacity to 1 block`);
|
|
@@ -608,7 +627,12 @@ export class StreamUploader {
|
|
|
608
627
|
}
|
|
609
628
|
}
|
|
610
629
|
|
|
611
|
-
protected verifyIntegrity(
|
|
630
|
+
protected verifyIntegrity(
|
|
631
|
+
thumbnails: Thumbnail[],
|
|
632
|
+
digests: { sha1: string },
|
|
633
|
+
): {
|
|
634
|
+
checksumVerified: boolean;
|
|
635
|
+
} {
|
|
612
636
|
const expectedBlockCount =
|
|
613
637
|
Math.ceil(this.metadata.expectedSize / FILE_CHUNK_SIZE) + (thumbnails ? thumbnails?.length : 0);
|
|
614
638
|
if (this.uploadedBlockCount !== expectedBlockCount) {
|
|
@@ -629,6 +653,9 @@ export class StreamUploader {
|
|
|
629
653
|
expectedSha1: this.metadata.expectedSha1,
|
|
630
654
|
});
|
|
631
655
|
}
|
|
656
|
+
return {
|
|
657
|
+
checksumVerified: !!(this.metadata.expectedSha1 && digests.sha1 === this.metadata.expectedSha1),
|
|
658
|
+
};
|
|
632
659
|
}
|
|
633
660
|
|
|
634
661
|
/**
|
|
@@ -690,6 +690,21 @@ export class ProtonDrivePhotosClient {
|
|
|
690
690
|
yield* this.photos.albums.removePhotos(getUid(albumNodeUid), getUids(photoNodeUids), signal);
|
|
691
691
|
}
|
|
692
692
|
|
|
693
|
+
/**
|
|
694
|
+
* Saves photos to the timeline.
|
|
695
|
+
*
|
|
696
|
+
* @param photoNodeUids - The UIDs of the photos to save to the timeline.
|
|
697
|
+
* @param signal - An optional abort signal to cancel the operation.
|
|
698
|
+
* @returns An async generator of per-photo results.
|
|
699
|
+
*/
|
|
700
|
+
async *savePhotosToTimeline(
|
|
701
|
+
photoNodeUids: NodeOrUid[],
|
|
702
|
+
signal?: AbortSignal,
|
|
703
|
+
): AsyncGenerator<NodeResultWithError> {
|
|
704
|
+
this.logger.info(`Saving ${photoNodeUids.length} photos to timeline`);
|
|
705
|
+
yield* this.photos.photos.saveToTimeline(getUids(photoNodeUids), signal);
|
|
706
|
+
}
|
|
707
|
+
|
|
693
708
|
/**
|
|
694
709
|
* Updates photos with the given settings: add or remove tags.
|
|
695
710
|
*
|