@protontech/drive-sdk 0.14.7 → 0.14.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 (62) hide show
  1. package/dist/interface/nodes.d.ts +1 -0
  2. package/dist/interface/nodes.js.map +1 -1
  3. package/dist/internal/apiService/driveTypes.d.ts +294 -261
  4. package/dist/internal/nodes/apiService.js +1 -0
  5. package/dist/internal/nodes/apiService.js.map +1 -1
  6. package/dist/internal/nodes/cache.test.js +1 -0
  7. package/dist/internal/nodes/cache.test.js.map +1 -1
  8. package/dist/internal/nodes/cryptoService.js +1 -0
  9. package/dist/internal/nodes/cryptoService.js.map +1 -1
  10. package/dist/internal/nodes/interface.d.ts +2 -0
  11. package/dist/internal/nodes/nodesAccess.js +4 -0
  12. package/dist/internal/nodes/nodesAccess.js.map +1 -1
  13. package/dist/internal/nodes/nodesRevisions.js +8 -0
  14. package/dist/internal/nodes/nodesRevisions.js.map +1 -1
  15. package/dist/internal/photos/albumsManager.js +9 -0
  16. package/dist/internal/photos/albumsManager.js.map +1 -1
  17. package/dist/internal/photos/albumsManager.test.js +37 -0
  18. package/dist/internal/photos/albumsManager.test.js.map +1 -1
  19. package/dist/internal/photos/apiService.js +4 -1
  20. package/dist/internal/photos/apiService.js.map +1 -1
  21. package/dist/internal/photos/nodes.d.ts +15 -4
  22. package/dist/internal/photos/nodes.js +26 -0
  23. package/dist/internal/photos/nodes.js.map +1 -1
  24. package/dist/internal/photos/nodes.test.js +28 -0
  25. package/dist/internal/photos/nodes.test.js.map +1 -1
  26. package/dist/internal/photos/upload.d.ts +4 -1
  27. package/dist/internal/photos/upload.js +8 -4
  28. package/dist/internal/photos/upload.js.map +1 -1
  29. package/dist/internal/upload/apiService.d.ts +3 -0
  30. package/dist/internal/upload/apiService.js +3 -0
  31. package/dist/internal/upload/apiService.js.map +1 -1
  32. package/dist/internal/upload/manager.d.ts +4 -1
  33. package/dist/internal/upload/manager.js +6 -2
  34. package/dist/internal/upload/manager.js.map +1 -1
  35. package/dist/internal/upload/smallFileUploader.d.ts +1 -0
  36. package/dist/internal/upload/smallFileUploader.js +1 -0
  37. package/dist/internal/upload/smallFileUploader.js.map +1 -1
  38. package/dist/internal/upload/streamUploader.d.ts +3 -2
  39. package/dist/internal/upload/streamUploader.js +40 -22
  40. package/dist/internal/upload/streamUploader.js.map +1 -1
  41. package/dist/internal/upload/streamUploader.test.js +3 -1
  42. package/dist/internal/upload/streamUploader.test.js.map +1 -1
  43. package/package.json +2 -1
  44. package/src/interface/nodes.ts +1 -0
  45. package/src/internal/apiService/driveTypes.ts +294 -261
  46. package/src/internal/nodes/apiService.ts +1 -0
  47. package/src/internal/nodes/cache.test.ts +1 -0
  48. package/src/internal/nodes/cryptoService.ts +1 -0
  49. package/src/internal/nodes/interface.ts +2 -0
  50. package/src/internal/nodes/nodesAccess.ts +4 -0
  51. package/src/internal/nodes/nodesRevisions.ts +8 -0
  52. package/src/internal/photos/albumsManager.test.ts +41 -0
  53. package/src/internal/photos/albumsManager.ts +9 -0
  54. package/src/internal/photos/apiService.ts +6 -1
  55. package/src/internal/photos/nodes.test.ts +42 -0
  56. package/src/internal/photos/nodes.ts +29 -0
  57. package/src/internal/photos/upload.ts +13 -2
  58. package/src/internal/upload/apiService.ts +6 -0
  59. package/src/internal/upload/manager.ts +7 -1
  60. package/src/internal/upload/smallFileUploader.ts +3 -0
  61. package/src/internal/upload/streamUploader.test.ts +3 -0
  62. package/src/internal/upload/streamUploader.ts +50 -23
@@ -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
 
@@ -123,6 +123,7 @@ describe('nodesCache', () => {
123
123
  claimedSize: 100,
124
124
  claimedDigests: {
125
125
  sha1: 'hash',
126
+ sha1Verified: true,
126
127
  },
127
128
  claimedBlockSizes: [100],
128
129
  claimedAdditionalMetadata: {
@@ -509,6 +509,7 @@ export class NodesCryptoService {
509
509
  contentAuthor,
510
510
  extendedAttributes,
511
511
  thumbnails: encryptedRevision.thumbnails,
512
+ sha1Verified: encryptedRevision.sha1Verified,
512
513
  };
513
514
  }
514
515
 
@@ -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 (!duplicate.Hash || !duplicate.ContentHash || duplicate.LinkState !== 1 /* Active */) {
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(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto, photo);
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, nodeCommitCrypto);
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
- (uploadedBytes) => {
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
- if (blockProgress !== 0) {
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
- (uploadedBytes) => {
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
- if (blockProgress !== 0) {
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(thumbnails: Thumbnail[], digests: { sha1: string }) {
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
  /**