@protontech/drive-sdk 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/errors.d.ts +1 -1
  2. package/dist/errors.js +2 -2
  3. package/dist/errors.js.map +1 -1
  4. package/dist/interface/download.d.ts +14 -0
  5. package/dist/internal/download/controller.d.ts +3 -0
  6. package/dist/internal/download/controller.js +7 -0
  7. package/dist/internal/download/controller.js.map +1 -1
  8. package/dist/internal/download/cryptoService.js +9 -2
  9. package/dist/internal/download/cryptoService.js.map +1 -1
  10. package/dist/internal/download/fileDownloader.js +9 -3
  11. package/dist/internal/download/fileDownloader.js.map +1 -1
  12. package/dist/internal/download/fileDownloader.test.js +14 -11
  13. package/dist/internal/download/fileDownloader.test.js.map +1 -1
  14. package/dist/internal/download/interface.d.ts +14 -0
  15. package/dist/internal/download/interface.js +16 -0
  16. package/dist/internal/download/interface.js.map +1 -1
  17. package/dist/internal/nodes/apiService.d.ts +1 -0
  18. package/dist/internal/nodes/apiService.js +3 -0
  19. package/dist/internal/nodes/apiService.js.map +1 -1
  20. package/dist/internal/nodes/nodesManagement.d.ts +1 -0
  21. package/dist/internal/nodes/nodesManagement.js +5 -0
  22. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  23. package/dist/internal/upload/apiService.d.ts +1 -0
  24. package/dist/internal/upload/apiService.js +12 -0
  25. package/dist/internal/upload/apiService.js.map +1 -1
  26. package/dist/internal/upload/manager.js +19 -1
  27. package/dist/internal/upload/manager.js.map +1 -1
  28. package/dist/internal/upload/manager.test.js +23 -0
  29. package/dist/internal/upload/manager.test.js.map +1 -1
  30. package/dist/internal/upload/streamUploader.js +1 -1
  31. package/dist/internal/upload/streamUploader.js.map +1 -1
  32. package/dist/protonDriveClient.js +1 -1
  33. package/dist/protonDriveClient.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/errors.ts +2 -2
  36. package/src/interface/download.ts +16 -0
  37. package/src/internal/download/controller.ts +9 -0
  38. package/src/internal/download/cryptoService.ts +13 -3
  39. package/src/internal/download/fileDownloader.test.ts +17 -11
  40. package/src/internal/download/fileDownloader.ts +9 -5
  41. package/src/internal/download/interface.ts +15 -0
  42. package/src/internal/nodes/apiService.ts +7 -0
  43. package/src/internal/nodes/nodesManagement.ts +6 -0
  44. package/src/internal/upload/apiService.ts +25 -0
  45. package/src/internal/upload/manager.test.ts +37 -0
  46. package/src/internal/upload/manager.ts +17 -1
  47. package/src/internal/upload/streamUploader.ts +1 -1
  48. package/src/protonDriveClient.ts +1 -1
@@ -1,7 +1,7 @@
1
1
  import { c } from 'ttag';
2
2
 
3
3
  import { PrivateKey, SessionKey, base64StringToUint8Array } from '../../crypto';
4
- import { AbortError } from '../../errors';
4
+ import { AbortError, IntegrityError } from '../../errors';
5
5
  import { Logger } from '../../interface';
6
6
  import { LoggerWithPrefix } from '../../telemetry';
7
7
  import { APIHTTPError, HTTPErrorCode } from '../apiService';
@@ -10,7 +10,7 @@ import { DownloadAPIService } from './apiService';
10
10
  import { getBlockIndex } from './blockIndex';
11
11
  import { DownloadController } from './controller';
12
12
  import { DownloadCryptoService } from './cryptoService';
13
- import { BlockMetadata, RevisionKeys } from './interface';
13
+ import { BlockMetadata, RevisionKeys, SignatureVerificationError } from './interface';
14
14
  import { BufferedSeekableStream } from './seekableStream';
15
15
  import { DownloadTelemetry } from './telemetry';
16
16
 
@@ -233,13 +233,17 @@ export class FileDownloader {
233
233
  );
234
234
  }
235
235
 
236
- await writer.close();
237
236
  void this.telemetry.downloadFinished(this.revision.uid, fileProgress);
238
237
  this.logger.info(`Download succeeded`);
239
238
  } catch (error: unknown) {
240
- this.logger.error(`Download failed`, error);
239
+ if (error instanceof SignatureVerificationError) {
240
+ this.logger.warn(`Download finished with signature verification issues`);
241
+ this.controller.setIsDownloadCompleteWithSignatureIssues(true);
242
+ error = new IntegrityError(error.message, error.debug, { cause: error });
243
+ } else {
244
+ this.logger.error(`Download failed`, error);
245
+ }
241
246
  void this.telemetry.downloadFailed(this.revision.uid, error, fileProgress, this.getClaimedSizeInBytes());
242
- await writer.abort?.();
243
247
  throw error;
244
248
  } finally {
245
249
  this.logger.debug(`Download cleanup`);
@@ -1,4 +1,5 @@
1
1
  import { PrivateKey, PublicKey, SessionKey } from '../../crypto';
2
+ import { IntegrityError } from '../../errors';
2
3
  import { NodeType, Result, MissingNode, MetricVolumeType } from '../../interface';
3
4
  import { DecryptedNode, DecryptedRevision } from '../nodes';
4
5
 
@@ -35,3 +36,17 @@ export interface NodesServiceNode {
35
36
  export interface RevisionsService {
36
37
  getRevision(nodeRevisionUid: string): Promise<DecryptedRevision>;
37
38
  }
39
+
40
+ /**
41
+ * Error thrown when the manifest signature verification fails.
42
+ * This is a special case that is reported as download complete with signature
43
+ * issues. The client must then ask the user to agree to save the file anyway
44
+ * or abort and clean up the file.
45
+ *
46
+ * This error is not exposed to the client. It is only used internally to track
47
+ * the signature verification issues. For the client it must be reported as
48
+ * the IntegrityError.
49
+ */
50
+ export class SignatureVerificationError extends IntegrityError {
51
+ name = 'SignatureVerificationError';
52
+ }
@@ -57,6 +57,9 @@ type PostCopyNodeRequest = Extract<
57
57
  type PostCopyNodeResponse =
58
58
  drivePaths['/drive/volumes/{volumeID}/links/{linkID}/copy']['post']['responses']['200']['content']['application/json'];
59
59
 
60
+ type EmptyTrashResponse =
61
+ drivePaths['/drive/volumes/{volumeID}/trash']['delete']['responses']['200']['content']['application/json'];
62
+
60
63
  type PostTrashNodesRequest = Extract<
61
64
  drivePaths['/drive/v2/volumes/{volumeID}/trash_multiple']['post']['requestBody'],
62
65
  { content: object }
@@ -436,6 +439,10 @@ export abstract class NodeAPIServiceBase<
436
439
  }
437
440
  }
438
441
 
442
+ async emptyTrash(volumeId: string): Promise<void> {
443
+ await this.apiService.delete<EmptyTrashResponse>(`drive/volumes/${volumeId}/trash`);
444
+ }
445
+
439
446
  async *restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
440
447
  for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) {
441
448
  const response = await this.apiService.put<PutRestoreNodesRequest, PutRestoreNodesResponse>(
@@ -127,6 +127,12 @@ export abstract class NodesManagementBase<
127
127
  }
128
128
  }
129
129
 
130
+ async emptyTrash(): Promise<void> {
131
+ const node = await this.nodesAccess.getVolumeRootFolder();
132
+ const { volumeId } = splitNodeUid(node.uid);
133
+ await this.apiService.emptyTrash(volumeId);
134
+ }
135
+
130
136
  async moveNode(nodeUid: string, newParentUid: string): Promise<TDecryptedNode> {
131
137
  const node = await this.nodesAccess.getNode(nodeUid);
132
138
 
@@ -45,6 +45,13 @@ type PostDeleteNodesRequest = Extract<
45
45
  type PostDeleteNodesResponse =
46
46
  drivePaths['/drive/v2/volumes/{volumeID}/delete_multiple']['post']['responses']['200']['content']['application/json'];
47
47
 
48
+ type PostLoadLinksMetadataRequest = Extract<
49
+ drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['requestBody'],
50
+ { content: object }
51
+ >['content']['application/json'];
52
+ type PostLoadLinksMetadataResponse =
53
+ drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json'];
54
+
48
55
  export class UploadAPIService {
49
56
  constructor(
50
57
  protected apiService: DriveAPIService,
@@ -262,4 +269,22 @@ export class UploadAPIService {
262
269
 
263
270
  await this.apiService.postBlockStream(url, token, formData, onProgress, signal);
264
271
  }
272
+
273
+ async isRevisionUploaded(nodeRevisionUid: string): Promise<boolean> {
274
+ const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid);
275
+ const result = await this.apiService.post<PostLoadLinksMetadataRequest, PostLoadLinksMetadataResponse>(
276
+ `drive/v2/volumes/${volumeId}/links`,
277
+ {
278
+ LinkIDs: [nodeId],
279
+ },
280
+ );
281
+ if (result.Links.length === 0) {
282
+ return false;
283
+ }
284
+ const link = result.Links[0];
285
+ return (
286
+ link.Link.State === 1 && // ACTIVE state
287
+ link.File?.ActiveRevision?.RevisionID === revisionId
288
+ );
289
+ }
265
290
  }
@@ -327,5 +327,42 @@ describe('UploadManager', () => {
327
327
  expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('parentUid');
328
328
  expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
329
329
  });
330
+
331
+ it('should ignore error if revision was committed successfully', async () => {
332
+ apiService.commitDraftRevision = jest
333
+ .fn()
334
+ .mockRejectedValue(new Error('Revision to commit must be a draft'));
335
+ apiService.isRevisionUploaded = jest.fn().mockResolvedValue(true);
336
+
337
+ await manager.commitDraft(nodeRevisionDraft as any, manifest, extendedAttributes);
338
+
339
+ expect(apiService.commitDraftRevision).toHaveBeenCalledWith(
340
+ nodeRevisionDraft.nodeRevisionUid,
341
+ expect.anything(),
342
+ );
343
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalled();
344
+ });
345
+
346
+ it('should throw error if revision was not committed successfully', async () => {
347
+ apiService.commitDraftRevision = jest
348
+ .fn()
349
+ .mockRejectedValue(new Error('Revision to commit must be a draft'));
350
+ apiService.isRevisionUploaded = jest.fn().mockResolvedValue(false);
351
+
352
+ await expect(manager.commitDraft(nodeRevisionDraft as any, manifest, extendedAttributes)).rejects.toThrow(
353
+ 'Revision to commit must be a draft',
354
+ );
355
+ expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
356
+ });
357
+
358
+ it('should throw original error if revision cannot be verified', async () => {
359
+ apiService.commitDraftRevision = jest.fn().mockRejectedValue(new Error('Failed to commit revision'));
360
+ apiService.isRevisionUploaded = jest.fn().mockRejectedValue(new Error('Failed to verify revision'));
361
+
362
+ await expect(manager.commitDraft(nodeRevisionDraft as any, manifest, extendedAttributes)).rejects.toThrow(
363
+ 'Failed to commit revision',
364
+ );
365
+ expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
366
+ });
330
367
  });
331
368
  });
@@ -246,7 +246,23 @@ export class UploadManager {
246
246
  manifest,
247
247
  generatedExtendedAttributes,
248
248
  );
249
- await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto);
249
+ try {
250
+ await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto);
251
+ } catch (error: unknown) {
252
+ // Commit might be sent but due to network error no response is
253
+ // received. In this case, API service automatically retries the
254
+ // request. If the first attempt passed, it will fail on the second
255
+ // attempt. We need to check if the revision was actually committed.
256
+ try {
257
+ const isRevisionUploaded = await this.apiService.isRevisionUploaded(nodeRevisionDraft.nodeRevisionUid);
258
+ if (!isRevisionUploaded) {
259
+ throw error;
260
+ }
261
+ } catch {
262
+ throw error; // Throw original error, not the checking one.
263
+ }
264
+ this.logger.warn(`Node commit failed but node was committed successfully ${nodeRevisionDraft.nodeUid}`);
265
+ }
250
266
  await this.notifyNodeUploaded(nodeRevisionDraft);
251
267
  }
252
268
 
@@ -181,7 +181,7 @@ export class StreamUploader {
181
181
  await this.controller.waitWhilePaused();
182
182
  await this.waitForUploadCapacityAndBufferedBlocks();
183
183
 
184
- if (this.isEncryptionFullyFinished) {
184
+ if (this.isEncryptionFullyFinished || this.isUploadAborted) {
185
185
  break;
186
186
  }
187
187
 
@@ -514,7 +514,7 @@ export class ProtonDriveClient {
514
514
 
515
515
  async emptyTrash(): Promise<void> {
516
516
  this.logger.info('Emptying trash');
517
- throw new Error('Method not implemented');
517
+ return this.nodes.management.emptyTrash();
518
518
  }
519
519
 
520
520
  /**