@protontech/drive-sdk 0.3.1 → 0.4.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 (181) hide show
  1. package/dist/crypto/driveCrypto.d.ts +1 -1
  2. package/dist/crypto/driveCrypto.js.map +1 -1
  3. package/dist/crypto/interface.d.ts +1 -1
  4. package/dist/crypto/openPGPCrypto.d.ts +1 -1
  5. package/dist/crypto/openPGPCrypto.js +4 -1
  6. package/dist/crypto/openPGPCrypto.js.map +1 -1
  7. package/dist/internal/apiService/errorCodes.d.ts +1 -0
  8. package/dist/internal/apiService/errors.d.ts +3 -0
  9. package/dist/internal/apiService/errors.js +7 -1
  10. package/dist/internal/apiService/errors.js.map +1 -1
  11. package/dist/internal/devices/interface.d.ts +1 -1
  12. package/dist/internal/devices/manager.js +1 -1
  13. package/dist/internal/devices/manager.js.map +1 -1
  14. package/dist/internal/devices/manager.test.js +3 -3
  15. package/dist/internal/devices/manager.test.js.map +1 -1
  16. package/dist/internal/download/cryptoService.js +2 -2
  17. package/dist/internal/download/cryptoService.js.map +1 -1
  18. package/dist/internal/download/fileDownloader.js +2 -2
  19. package/dist/internal/download/fileDownloader.js.map +1 -1
  20. package/dist/internal/download/fileDownloader.test.js +3 -1
  21. package/dist/internal/download/fileDownloader.test.js.map +1 -1
  22. package/dist/internal/events/apiService.js +1 -1
  23. package/dist/internal/events/apiService.js.map +1 -1
  24. package/dist/internal/events/coreEventManager.js +1 -1
  25. package/dist/internal/events/coreEventManager.js.map +1 -1
  26. package/dist/internal/events/coreEventManager.test.js +18 -24
  27. package/dist/internal/events/coreEventManager.test.js.map +1 -1
  28. package/dist/internal/events/index.d.ts +3 -4
  29. package/dist/internal/events/index.js +4 -4
  30. package/dist/internal/events/index.js.map +1 -1
  31. package/dist/internal/events/interface.d.ts +3 -0
  32. package/dist/internal/nodes/apiService.d.ts +12 -3
  33. package/dist/internal/nodes/apiService.js +53 -13
  34. package/dist/internal/nodes/apiService.js.map +1 -1
  35. package/dist/internal/nodes/apiService.test.js +19 -2
  36. package/dist/internal/nodes/apiService.test.js.map +1 -1
  37. package/dist/internal/nodes/cache.js +3 -1
  38. package/dist/internal/nodes/cache.js.map +1 -1
  39. package/dist/internal/nodes/cryptoReporter.d.ts +20 -0
  40. package/dist/internal/nodes/cryptoReporter.js +96 -0
  41. package/dist/internal/nodes/cryptoReporter.js.map +1 -0
  42. package/dist/internal/nodes/cryptoService.d.ts +18 -13
  43. package/dist/internal/nodes/cryptoService.js +18 -98
  44. package/dist/internal/nodes/cryptoService.js.map +1 -1
  45. package/dist/internal/nodes/cryptoService.test.js +7 -5
  46. package/dist/internal/nodes/cryptoService.test.js.map +1 -1
  47. package/dist/internal/nodes/errors.d.ts +4 -0
  48. package/dist/internal/nodes/errors.js +9 -0
  49. package/dist/internal/nodes/errors.js.map +1 -0
  50. package/dist/internal/nodes/index.js +3 -1
  51. package/dist/internal/nodes/index.js.map +1 -1
  52. package/dist/internal/nodes/index.test.js +1 -1
  53. package/dist/internal/nodes/index.test.js.map +1 -1
  54. package/dist/internal/nodes/interface.d.ts +5 -2
  55. package/dist/internal/nodes/nodesAccess.d.ts +4 -4
  56. package/dist/internal/nodes/nodesAccess.js +77 -69
  57. package/dist/internal/nodes/nodesAccess.js.map +1 -1
  58. package/dist/internal/nodes/nodesAccess.test.js +48 -8
  59. package/dist/internal/nodes/nodesAccess.test.js.map +1 -1
  60. package/dist/internal/nodes/nodesManagement.d.ts +2 -0
  61. package/dist/internal/nodes/nodesManagement.js +86 -9
  62. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  63. package/dist/internal/nodes/nodesManagement.test.js +81 -5
  64. package/dist/internal/nodes/nodesManagement.test.js.map +1 -1
  65. package/dist/internal/photos/albums.d.ts +9 -7
  66. package/dist/internal/photos/albums.js +26 -13
  67. package/dist/internal/photos/albums.js.map +1 -1
  68. package/dist/internal/photos/apiService.d.ts +34 -3
  69. package/dist/internal/photos/apiService.js +96 -3
  70. package/dist/internal/photos/apiService.js.map +1 -1
  71. package/dist/internal/photos/index.d.ts +20 -4
  72. package/dist/internal/photos/index.js +30 -7
  73. package/dist/internal/photos/index.js.map +1 -1
  74. package/dist/internal/photos/interface.d.ts +25 -1
  75. package/dist/internal/photos/shares.d.ts +43 -0
  76. package/dist/internal/photos/shares.js +112 -0
  77. package/dist/internal/photos/shares.js.map +1 -0
  78. package/dist/internal/photos/timeline.d.ts +15 -0
  79. package/dist/internal/photos/timeline.js +22 -0
  80. package/dist/internal/photos/timeline.js.map +1 -0
  81. package/dist/internal/shares/manager.d.ts +1 -1
  82. package/dist/internal/shares/manager.js +4 -4
  83. package/dist/internal/shares/manager.js.map +1 -1
  84. package/dist/internal/shares/manager.test.js +7 -7
  85. package/dist/internal/shares/manager.test.js.map +1 -1
  86. package/dist/internal/sharing/cache.d.ts +3 -0
  87. package/dist/internal/sharing/cache.js +17 -2
  88. package/dist/internal/sharing/cache.js.map +1 -1
  89. package/dist/internal/sharing/interface.d.ts +2 -2
  90. package/dist/internal/sharing/interface.js +1 -1
  91. package/dist/internal/sharing/sharingAccess.js +7 -1
  92. package/dist/internal/sharing/sharingAccess.js.map +1 -1
  93. package/dist/internal/sharing/sharingAccess.test.js +243 -34
  94. package/dist/internal/sharing/sharingAccess.test.js.map +1 -1
  95. package/dist/internal/sharingPublic/apiService.d.ts +1 -1
  96. package/dist/internal/sharingPublic/apiService.js +9 -2
  97. package/dist/internal/sharingPublic/apiService.js.map +1 -1
  98. package/dist/internal/sharingPublic/cryptoService.d.ts +6 -20
  99. package/dist/internal/sharingPublic/cryptoService.js +40 -103
  100. package/dist/internal/sharingPublic/cryptoService.js.map +1 -1
  101. package/dist/internal/sharingPublic/index.d.ts +2 -2
  102. package/dist/internal/sharingPublic/index.js +2 -2
  103. package/dist/internal/sharingPublic/index.js.map +1 -1
  104. package/dist/internal/sharingPublic/interface.d.ts +1 -43
  105. package/dist/internal/sharingPublic/manager.d.ts +1 -1
  106. package/dist/internal/sharingPublic/manager.js +9 -7
  107. package/dist/internal/sharingPublic/manager.js.map +1 -1
  108. package/dist/internal/upload/streamUploader.js +1 -1
  109. package/dist/internal/upload/streamUploader.js.map +1 -1
  110. package/dist/internal/upload/streamUploader.test.js +3 -1
  111. package/dist/internal/upload/streamUploader.test.js.map +1 -1
  112. package/dist/protonDriveClient.d.ts +20 -3
  113. package/dist/protonDriveClient.js +24 -4
  114. package/dist/protonDriveClient.js.map +1 -1
  115. package/dist/protonDrivePhotosClient.d.ts +86 -12
  116. package/dist/protonDrivePhotosClient.js +132 -29
  117. package/dist/protonDrivePhotosClient.js.map +1 -1
  118. package/dist/protonDrivePublicLinkClient.d.ts +13 -4
  119. package/dist/protonDrivePublicLinkClient.js +13 -11
  120. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  121. package/package.json +1 -1
  122. package/src/crypto/driveCrypto.ts +1 -1
  123. package/src/crypto/interface.ts +1 -1
  124. package/src/crypto/openPGPCrypto.ts +5 -2
  125. package/src/internal/apiService/errorCodes.ts +1 -0
  126. package/src/internal/apiService/errors.ts +6 -0
  127. package/src/internal/devices/interface.ts +1 -1
  128. package/src/internal/devices/manager.test.ts +3 -3
  129. package/src/internal/devices/manager.ts +1 -1
  130. package/src/internal/download/cryptoService.ts +2 -2
  131. package/src/internal/download/fileDownloader.test.ts +3 -1
  132. package/src/internal/download/fileDownloader.ts +2 -2
  133. package/src/internal/events/apiService.ts +1 -1
  134. package/src/internal/events/coreEventManager.test.ts +21 -27
  135. package/src/internal/events/coreEventManager.ts +1 -1
  136. package/src/internal/events/index.ts +3 -4
  137. package/src/internal/events/interface.ts +4 -0
  138. package/src/internal/nodes/apiService.test.ts +35 -1
  139. package/src/internal/nodes/apiService.ts +103 -17
  140. package/src/internal/nodes/cache.ts +3 -1
  141. package/src/internal/nodes/cryptoReporter.ts +145 -0
  142. package/src/internal/nodes/cryptoService.test.ts +11 -9
  143. package/src/internal/nodes/cryptoService.ts +45 -138
  144. package/src/internal/nodes/errors.ts +5 -0
  145. package/src/internal/nodes/index.test.ts +1 -1
  146. package/src/internal/nodes/index.ts +3 -1
  147. package/src/internal/nodes/interface.ts +6 -2
  148. package/src/internal/nodes/nodesAccess.test.ts +68 -8
  149. package/src/internal/nodes/nodesAccess.ts +101 -76
  150. package/src/internal/nodes/nodesManagement.test.ts +100 -5
  151. package/src/internal/nodes/nodesManagement.ts +100 -13
  152. package/src/internal/photos/albums.ts +31 -12
  153. package/src/internal/photos/apiService.ts +159 -4
  154. package/src/internal/photos/index.ts +54 -9
  155. package/src/internal/photos/interface.ts +23 -1
  156. package/src/internal/photos/shares.ts +134 -0
  157. package/src/internal/photos/timeline.ts +24 -0
  158. package/src/internal/shares/manager.test.ts +7 -7
  159. package/src/internal/shares/manager.ts +4 -4
  160. package/src/internal/sharing/cache.ts +19 -2
  161. package/src/internal/sharing/interface.ts +2 -2
  162. package/src/internal/sharing/sharingAccess.test.ts +283 -35
  163. package/src/internal/sharing/sharingAccess.ts +7 -1
  164. package/src/internal/sharingPublic/apiService.ts +11 -2
  165. package/src/internal/sharingPublic/cryptoService.ts +71 -135
  166. package/src/internal/sharingPublic/index.ts +3 -2
  167. package/src/internal/sharingPublic/interface.ts +8 -53
  168. package/src/internal/sharingPublic/manager.ts +9 -8
  169. package/src/internal/upload/streamUploader.test.ts +3 -1
  170. package/src/internal/upload/streamUploader.ts +1 -1
  171. package/src/protonDriveClient.ts +34 -4
  172. package/src/protonDrivePhotosClient.ts +211 -32
  173. package/src/protonDrivePublicLinkClient.ts +26 -12
  174. package/dist/internal/photos/cache.d.ts +0 -6
  175. package/dist/internal/photos/cache.js +0 -15
  176. package/dist/internal/photos/cache.js.map +0 -1
  177. package/dist/internal/photos/photosTimeline.d.ts +0 -10
  178. package/dist/internal/photos/photosTimeline.js +0 -19
  179. package/dist/internal/photos/photosTimeline.js.map +0 -1
  180. package/src/internal/photos/cache.ts +0 -11
  181. package/src/internal/photos/photosTimeline.ts +0 -17
@@ -18,6 +18,7 @@ export const enum ErrorCode {
18
18
  OK = 1000,
19
19
  OK_MANY = 1001,
20
20
  OK_ASYNC = 1002,
21
+ INVALID_REQUIREMENTS = 2000,
21
22
  INVALID_VALUE = 2001,
22
23
  NOT_ENOUGH_PERMISSIONS = 2011,
23
24
  NOT_ENOUGH_PERMISSIONS_TO_GRANT_PERMISSIONS = 2026,
@@ -57,6 +57,8 @@ export function apiErrorFactory({
57
57
  // Here we convert only general enough codes. Specific cases that are
58
58
  // not clear from the code itself must be handled by each module
59
59
  // separately.
60
+ case ErrorCode.INVALID_REQUIREMENTS:
61
+ return new InvalidRequirementsAPIError(message, code, details);
60
62
  case ErrorCode.INVALID_VALUE:
61
63
  case ErrorCode.NOT_ENOUGH_PERMISSIONS:
62
64
  case ErrorCode.NOT_ENOUGH_PERMISSIONS_TO_GRANT_PERMISSIONS:
@@ -108,3 +110,7 @@ export class APICodeError extends ServerError {
108
110
  export class NotFoundAPIError extends ValidationError {
109
111
  name = 'NotFoundAPIError';
110
112
  }
113
+
114
+ export class InvalidRequirementsAPIError extends ValidationError {
115
+ name = 'InvalidRequirementsAPIError';
116
+ }
@@ -13,7 +13,7 @@ export type DeviceMetadata = {
13
13
  };
14
14
 
15
15
  export interface SharesService {
16
- getMyFilesIDs(): Promise<{ volumeId: string }>;
16
+ getOwnVolumeIDs(): Promise<{ volumeId: string }>;
17
17
  getMyFilesShareMemberEmailKey(): Promise<{
18
18
  addressId: string;
19
19
  email: string;
@@ -30,7 +30,7 @@ describe('DevicesManager', () => {
30
30
  };
31
31
  // @ts-expect-error No need to implement all methods for mocking
32
32
  sharesService = {
33
- getMyFilesIDs: jest.fn(),
33
+ getOwnVolumeIDs: jest.fn(),
34
34
  };
35
35
  // @ts-expect-error No need to implement all methods for mocking
36
36
  nodesService = {};
@@ -74,13 +74,13 @@ describe('DevicesManager', () => {
74
74
  shareId: 'shareid',
75
75
  } as DeviceMetadata;
76
76
 
77
- sharesService.getMyFilesIDs.mockResolvedValue({ volumeId });
77
+ sharesService.getOwnVolumeIDs.mockResolvedValue({ volumeId });
78
78
  cryptoService.createDevice.mockResolvedValue({ address, shareKey, node });
79
79
  apiService.createDevice.mockResolvedValue(createdDevice);
80
80
 
81
81
  const result = await manager.createDevice(name, deviceType);
82
82
 
83
- expect(sharesService.getMyFilesIDs).toHaveBeenCalled();
83
+ expect(sharesService.getOwnVolumeIDs).toHaveBeenCalled();
84
84
  expect(cryptoService.createDevice).toHaveBeenCalledWith(name);
85
85
  expect(apiService.createDevice).toHaveBeenCalledWith(
86
86
  { volumeId, type: deviceType },
@@ -47,7 +47,7 @@ export class DevicesManager {
47
47
  }
48
48
 
49
49
  async createDevice(name: string, deviceType: DeviceType): Promise<Device> {
50
- const { volumeId } = await this.sharesService.getMyFilesIDs();
50
+ const { volumeId } = await this.sharesService.getOwnVolumeIDs();
51
51
  const { address, shareKey, node } = await this.cryptoService.createDevice(name);
52
52
 
53
53
  const device = await this.apiService.createDevice(
@@ -49,7 +49,7 @@ export class DownloadCryptoService {
49
49
  );
50
50
  } catch (error: unknown) {
51
51
  const message = getErrorMessage(error);
52
- throw new DecryptionError(c('Error').t`Failed to decrypt block: ${message}`);
52
+ throw new DecryptionError(c('Error').t`Failed to decrypt block: ${message}`, { cause: error });
53
53
  }
54
54
 
55
55
  return decryptedBlock;
@@ -66,7 +66,7 @@ export class DownloadCryptoService {
66
66
  decryptedBlock = result.decryptedThumbnail;
67
67
  } catch (error: unknown) {
68
68
  const message = getErrorMessage(error);
69
- throw new DecryptionError(c('Error').t`Failed to decrypt thumbnail: ${message}`);
69
+ throw new DecryptionError(c('Error').t`Failed to decrypt thumbnail: ${message}`, { cause: error });
70
70
  }
71
71
 
72
72
  return decryptedBlock;
@@ -123,8 +123,10 @@ describe('FileDownloader', () => {
123
123
 
124
124
  const verifyOnProgress = async (downloadedBytes: number[]) => {
125
125
  expect(onProgress).toHaveBeenCalledTimes(downloadedBytes.length);
126
+ let fileProgress = 0;
126
127
  for (let i = 0; i < downloadedBytes.length; i++) {
127
- expect(onProgress).toHaveBeenNthCalledWith(i + 1, downloadedBytes[i]);
128
+ fileProgress += downloadedBytes[i];
129
+ expect(onProgress).toHaveBeenNthCalledWith(i + 1, fileProgress);
128
130
  }
129
131
  };
130
132
 
@@ -134,7 +134,7 @@ export class FileDownloader {
134
134
  const blockData = await this.downloadBlockData(blockMetadata, true, cryptoKeys);
135
135
  return blockData.slice(blockOffset);
136
136
  } catch (error: unknown) {
137
- return error instanceof Error ? error : new Error(`Unknown error: ${error}`);
137
+ return error instanceof Error ? error : new Error(`Unknown error: ${error}`, { cause: error });
138
138
  }
139
139
  }
140
140
 
@@ -193,7 +193,7 @@ export class FileDownloader {
193
193
  cryptoKeys,
194
194
  (downloadedBytes) => {
195
195
  fileProgress += downloadedBytes;
196
- onProgress?.(downloadedBytes);
196
+ onProgress?.(fileProgress);
197
197
  },
198
198
  );
199
199
  this.ongoingDownloads.set(blockMetadata.index, { downloadPromise });
@@ -57,7 +57,7 @@ export class EventsAPIService {
57
57
  return {
58
58
  latestEventId: result.EventID,
59
59
  more: result.More === 1,
60
- refresh: result.Refresh === 1,
60
+ refresh,
61
61
  events,
62
62
  };
63
63
  }
@@ -46,29 +46,6 @@ describe('CoreEventManager', () => {
46
46
  const eventId = 'event1';
47
47
  const latestEventId = 'event2';
48
48
 
49
- it('should yield ShareWithMeUpdated event when refresh is true', async () => {
50
- const mockEvents: DriveEventsListWithStatus = {
51
- latestEventId,
52
- more: false,
53
- refresh: true,
54
- events: [],
55
- };
56
- mockApiService.getCoreEvents.mockResolvedValue(mockEvents);
57
-
58
- const events = [];
59
- for await (const event of coreEventManager.getEvents(eventId)) {
60
- events.push(event);
61
- }
62
-
63
- expect(events).toHaveLength(1);
64
- expect(events[0]).toEqual({
65
- type: DriveEventType.SharedWithMeUpdated,
66
- treeEventScopeId: 'core',
67
- eventId: latestEventId,
68
- });
69
- expect(mockApiService.getCoreEvents).toHaveBeenCalledWith(eventId);
70
- });
71
-
72
49
  it('should yield all events when there are actual events', async () => {
73
50
  const mockEvent1: DriveEvent = {
74
51
  type: DriveEventType.SharedWithMeUpdated,
@@ -88,14 +65,31 @@ describe('CoreEventManager', () => {
88
65
  };
89
66
  mockApiService.getCoreEvents.mockResolvedValue(mockEvents);
90
67
 
91
- const events = [];
92
- for await (const event of coreEventManager.getEvents(eventId)) {
93
- events.push(event);
94
- }
68
+ const events = await Array.fromAsync(coreEventManager.getEvents(eventId));
95
69
 
96
70
  expect(events).toHaveLength(2);
97
71
  expect(events[0]).toEqual(mockEvent1);
98
72
  expect(events[1]).toEqual(mockEvent2);
99
73
  });
74
+
75
+ it('should yield FastForward event there are no events but lastEventId changed', async () => {
76
+ const mockEvents: DriveEventsListWithStatus = {
77
+ latestEventId,
78
+ more: false,
79
+ refresh: false,
80
+ events: [],
81
+ };
82
+ mockApiService.getCoreEvents.mockResolvedValue(mockEvents);
83
+
84
+ const events = await Array.fromAsync(coreEventManager.getEvents(eventId));
85
+
86
+ expect(events).toHaveLength(1);
87
+ expect(events[0]).toEqual({
88
+ type: DriveEventType.FastForward,
89
+ treeEventScopeId: 'core',
90
+ eventId: latestEventId,
91
+ });
92
+ expect(mockApiService.getCoreEvents).toHaveBeenCalledWith(eventId);
93
+ });
100
94
  });
101
95
  });
@@ -32,7 +32,7 @@ export class CoreEventManager implements EventManagerInterface<DriveEvent> {
32
32
  const events = await this.apiService.getCoreEvents(eventId);
33
33
  if (events.events.length === 0 && events.latestEventId !== eventId) {
34
34
  yield {
35
- type: DriveEventType.SharedWithMeUpdated,
35
+ type: DriveEventType.FastForward,
36
36
  treeEventScopeId: 'core',
37
37
  eventId: events.latestEventId,
38
38
  };
@@ -1,11 +1,10 @@
1
1
  import { Logger, ProtonDriveTelemetry } from '../../interface';
2
2
  import { DriveAPIService } from '../apiService';
3
- import { DriveEvent, DriveListener, EventSubscription, LatestEventIdProvider } from './interface';
3
+ import { DriveEvent, DriveListener, EventSubscription, LatestEventIdProvider, SharesService } from './interface';
4
4
  import { EventsAPIService } from './apiService';
5
5
  import { CoreEventManager } from './coreEventManager';
6
6
  import { VolumeEventManager } from './volumeEventManager';
7
7
  import { EventManager } from './eventManager';
8
- import { SharesManager } from '../shares/manager';
9
8
 
10
9
  export type { DriveEvent, DriveListener, EventSubscription } from './interface';
11
10
  export { DriveEventType } from './interface';
@@ -28,7 +27,7 @@ export class DriveEventsService {
28
27
  constructor(
29
28
  private telemetry: ProtonDriveTelemetry,
30
29
  apiService: DriveAPIService,
31
- private shareManagement: SharesManager,
30
+ private sharesService: SharesService,
32
31
  private cacheEventListeners: DriveListener[] = [],
33
32
  private latestEventIdProvider?: LatestEventIdProvider,
34
33
  ) {
@@ -104,7 +103,7 @@ export class DriveEventsService {
104
103
  this.logger.debug(`Creating volume event manager for volume ${volumeId}`);
105
104
  const volumeEventManager = new VolumeEventManager(this.logger, this.apiService, volumeId);
106
105
 
107
- const isOwnVolume = await this.shareManagement.isOwnVolume(volumeId);
106
+ const isOwnVolume = await this.sharesService.isOwnVolume(volumeId);
108
107
  const pollingInterval = this.getDefaultVolumePollingInterval(isOwnVolume);
109
108
  const latestEventId = this.latestEventIdProvider.getLatestEventId(volumeId);
110
109
  const eventManager = new EventManager<DriveEvent>(volumeEventManager, pollingInterval, latestEventId);
@@ -112,3 +112,7 @@ export interface EventManagerInterface<T> {
112
112
  getEvents(eventId: string): AsyncIterable<T>;
113
113
  getLogger(): Logger;
114
114
  }
115
+
116
+ export interface SharesService {
117
+ isOwnVolume(volumeId: string): Promise<boolean>;
118
+ }
@@ -1,7 +1,8 @@
1
1
  import { MemberRole, NodeType } from '../../interface';
2
2
  import { getMockLogger } from '../../tests/logger';
3
- import { DriveAPIService, ErrorCode } from '../apiService';
3
+ import { DriveAPIService, ErrorCode, InvalidRequirementsAPIError } from '../apiService';
4
4
  import { NodeAPIService } from './apiService';
5
+ import { NodeOutOfSyncError } from './errors';
5
6
 
6
7
  function generateAPIFileNode(linkOverrides = {}, overrides = {}) {
7
8
  const node = generateAPINode();
@@ -542,4 +543,37 @@ describe('nodeAPIService', () => {
542
543
  }
543
544
  });
544
545
  });
546
+
547
+ describe('renameNode', () => {
548
+ it('should rename node', async () => {
549
+ await api.renameNode(
550
+ 'volumeId~nodeId1',
551
+ { hash: 'originalHash' },
552
+ { encryptedName: 'encryptedName1', nameSignatureEmail: 'nameSignatureEmail1', hash: 'newHash' },
553
+ );
554
+
555
+ expect(apiMock.put).toHaveBeenCalledWith(
556
+ 'drive/v2/volumes/volumeId/links/nodeId1/rename',
557
+ {
558
+ Name: 'encryptedName1',
559
+ NameSignatureEmail: 'nameSignatureEmail1',
560
+ Hash: 'newHash',
561
+ OriginalHash: 'originalHash',
562
+ },
563
+ undefined,
564
+ );
565
+ });
566
+
567
+ it('should throw error if node is out of sync', async () => {
568
+ apiMock.put = jest.fn().mockRejectedValue(new InvalidRequirementsAPIError('Node is out of sync'));
569
+
570
+ await expect(
571
+ api.renameNode(
572
+ 'volumeId~nodeId1',
573
+ { hash: 'originalHash' },
574
+ { encryptedName: 'encryptedName1', nameSignatureEmail: 'nameSignatureEmail1', hash: 'newHash' },
575
+ ),
576
+ ).rejects.toThrow(new NodeOutOfSyncError('Node is out of sync'));
577
+ });
578
+ });
545
579
  });
@@ -6,6 +6,7 @@ import { MemberRole, RevisionState } from '../../interface/nodes';
6
6
  import {
7
7
  DriveAPIService,
8
8
  drivePaths,
9
+ InvalidRequirementsAPIError,
9
10
  isCodeOk,
10
11
  nodeTypeNumberToNodeType,
11
12
  permissionsToMemberRole,
@@ -13,7 +14,8 @@ import {
13
14
  import { asyncIteratorRace } from '../asyncIteratorRace';
14
15
  import { batch } from '../batch';
15
16
  import { splitNodeUid, makeNodeUid, makeNodeRevisionUid, splitNodeRevisionUid, makeNodeThumbnailUid } from '../uids';
16
- import { EncryptedNode, EncryptedRevision, Thumbnail } from './interface';
17
+ import { NodeOutOfSyncError } from './errors';
18
+ import { EncryptedNode, EncryptedRevision, FilterOptions, Thumbnail } from './interface';
17
19
 
18
20
  // This is the number of calls to the API that are made in parallel.
19
21
  const API_CONCURRENCY = 15;
@@ -48,6 +50,13 @@ type PutMoveNodeRequest = Extract<
48
50
  type PutMoveNodeResponse =
49
51
  drivePaths['/drive/v2/volumes/{volumeID}/links/{linkID}/move']['put']['responses']['200']['content']['application/json'];
50
52
 
53
+ type PostCopyNodeRequest = Extract<
54
+ drivePaths['/drive/volumes/{volumeID}/links/{linkID}/copy']['post']['requestBody'],
55
+ { content: object }
56
+ >['content']['application/json'];
57
+ type PostCopyNodeResponse =
58
+ drivePaths['/drive/volumes/{volumeID}/links/{linkID}/copy']['post']['responses']['200']['content']['application/json'];
59
+
51
60
  type PostTrashNodesRequest = Extract<
52
61
  drivePaths['/drive/v2/volumes/{volumeID}/trash_multiple']['post']['requestBody'],
53
62
  { content: object }
@@ -108,7 +117,7 @@ export class NodeAPIService {
108
117
  }
109
118
 
110
119
  async getNode(nodeUid: string, ownVolumeId: string, signal?: AbortSignal): Promise<EncryptedNode> {
111
- const nodesGenerator = this.iterateNodes([nodeUid], ownVolumeId, signal);
120
+ const nodesGenerator = this.iterateNodes([nodeUid], ownVolumeId, undefined, signal);
112
121
  const result = await nodesGenerator.next();
113
122
  if (!result.value) {
114
123
  throw new ValidationError(c('Error').t`Node not found`);
@@ -117,7 +126,12 @@ export class NodeAPIService {
117
126
  return result.value;
118
127
  }
119
128
 
120
- async *iterateNodes(nodeUids: string[], ownVolumeId: string, signal?: AbortSignal): AsyncGenerator<EncryptedNode> {
129
+ async *iterateNodes(
130
+ nodeUids: string[],
131
+ ownVolumeId: string,
132
+ filterOptions?: FilterOptions,
133
+ signal?: AbortSignal,
134
+ ): AsyncGenerator<EncryptedNode> {
121
135
  const allNodeIds = nodeUids.map(splitNodeUid);
122
136
 
123
137
  const nodeIdsByVolumeId = new Map<string, string[]>();
@@ -139,7 +153,13 @@ export class NodeAPIService {
139
153
  const isAdmin = volumeId === ownVolumeId;
140
154
 
141
155
  yield (async function* () {
142
- const errorsPerVolume = yield* iterateNodesPerVolume(volumeId, nodeIds, isAdmin, signal);
156
+ const errorsPerVolume = yield* iterateNodesPerVolume(
157
+ volumeId,
158
+ nodeIds,
159
+ isAdmin,
160
+ filterOptions,
161
+ signal,
162
+ );
143
163
  if (errorsPerVolume.length) {
144
164
  errors.push(...errorsPerVolume);
145
165
  }
@@ -159,6 +179,7 @@ export class NodeAPIService {
159
179
  volumeId: string,
160
180
  nodeIds: string[],
161
181
  isOwnVolumeId: boolean,
182
+ filterOptions?: FilterOptions,
162
183
  signal?: AbortSignal,
163
184
  ): AsyncGenerator<EncryptedNode, unknown[]> {
164
185
  const errors: unknown[] = [];
@@ -174,7 +195,11 @@ export class NodeAPIService {
174
195
 
175
196
  for (const link of response.Links) {
176
197
  try {
177
- yield linkToEncryptedNode(this.logger, volumeId, link, isOwnVolumeId);
198
+ const encryptedNode = linkToEncryptedNode(this.logger, volumeId, link, isOwnVolumeId);
199
+ if (filterOptions?.type && encryptedNode.type !== filterOptions.type) {
200
+ continue;
201
+ }
202
+ yield encryptedNode;
178
203
  } catch (error: unknown) {
179
204
  this.logger.error(`Failed to transform node ${link.Link.LinkID}`, error);
180
205
  errors.push(error);
@@ -186,13 +211,25 @@ export class NodeAPIService {
186
211
  }
187
212
 
188
213
  // Improvement requested: load next page sooner before all IDs are yielded.
189
- async *iterateChildrenNodeUids(parentNodeUid: string, signal?: AbortSignal): AsyncGenerator<string> {
214
+ async *iterateChildrenNodeUids(
215
+ parentNodeUid: string,
216
+ onlyFolders: boolean = false,
217
+ signal?: AbortSignal,
218
+ ): AsyncGenerator<string> {
190
219
  const { volumeId, nodeId } = splitNodeUid(parentNodeUid);
191
220
 
192
221
  let anchor = '';
193
222
  while (true) {
223
+ const queryParams = new URLSearchParams();
224
+ if (onlyFolders) {
225
+ queryParams.set('FoldersOnly', '1');
226
+ }
227
+ if (anchor) {
228
+ queryParams.set('AnchorID', anchor);
229
+ }
230
+
194
231
  const response = await this.apiService.get<GetChildrenResponse>(
195
- `drive/v2/volumes/${volumeId}/folders/${nodeId}/children?${anchor ? `AnchorID=${anchor}` : ''}`,
232
+ `drive/v2/volumes/${volumeId}/folders/${nodeId}/children?${queryParams.toString()}`,
196
233
  signal,
197
234
  );
198
235
  for (const linkID of response.LinkIDs) {
@@ -251,16 +288,28 @@ export class NodeAPIService {
251
288
  ): Promise<void> {
252
289
  const { volumeId, nodeId } = splitNodeUid(nodeUid);
253
290
 
254
- await this.apiService.put<Omit<PutRenameNodeRequest, 'SignatureAddress' | 'MIMEType'>, PutRenameNodeResponse>(
255
- `drive/v2/volumes/${volumeId}/links/${nodeId}/rename`,
256
- {
257
- Name: newNode.encryptedName,
258
- NameSignatureEmail: newNode.nameSignatureEmail,
259
- Hash: newNode.hash,
260
- OriginalHash: originalNode.hash || null,
261
- },
262
- signal,
263
- );
291
+ try {
292
+ await this.apiService.put<
293
+ Omit<PutRenameNodeRequest, 'SignatureAddress' | 'MIMEType'>,
294
+ PutRenameNodeResponse
295
+ >(
296
+ `drive/v2/volumes/${volumeId}/links/${nodeId}/rename`,
297
+ {
298
+ Name: newNode.encryptedName,
299
+ NameSignatureEmail: newNode.nameSignatureEmail,
300
+ Hash: newNode.hash,
301
+ OriginalHash: originalNode.hash || null,
302
+ },
303
+ signal,
304
+ );
305
+ } catch (error: unknown) {
306
+ // API returns generic code 2000 when node is out of sync.
307
+ // We map this to specific error for clarity.
308
+ if (error instanceof InvalidRequirementsAPIError) {
309
+ throw new NodeOutOfSyncError(error.message, error.code, { cause: error });
310
+ }
311
+ throw error;
312
+ }
264
313
  }
265
314
 
266
315
  async moveNode(
@@ -303,6 +352,43 @@ export class NodeAPIService {
303
352
  );
304
353
  }
305
354
 
355
+ async copyNode(
356
+ nodeUid: string,
357
+ newNode: {
358
+ parentUid: string;
359
+ armoredNodePassphrase: string;
360
+ armoredNodePassphraseSignature?: string;
361
+ signatureEmail?: string;
362
+ encryptedName: string;
363
+ nameSignatureEmail?: string;
364
+ hash: string;
365
+ },
366
+ signal?: AbortSignal,
367
+ ): Promise<string> {
368
+ const { volumeId, nodeId } = splitNodeUid(nodeUid);
369
+ const { volumeId: parentVolumeId, nodeId: parentNodeId } = splitNodeUid(newNode.parentUid);
370
+
371
+ const response = await this.apiService.post<PostCopyNodeRequest, PostCopyNodeResponse>(
372
+ `drive/volumes/${volumeId}/links/${nodeId}/copy`,
373
+ {
374
+ TargetVolumeID: parentVolumeId,
375
+ TargetParentLinkID: parentNodeId,
376
+ NodePassphrase: newNode.armoredNodePassphrase,
377
+ // @ts-expect-error: API accepts NodePassphraseSignature as optional.
378
+ NodePassphraseSignature: newNode.armoredNodePassphraseSignature,
379
+ // @ts-expect-error: API accepts SignatureEmail as optional.
380
+ SignatureEmail: newNode.signatureEmail,
381
+ Name: newNode.encryptedName,
382
+ // @ts-expect-error: API accepts NameSignatureEmail as optional.
383
+ NameSignatureEmail: newNode.nameSignatureEmail,
384
+ Hash: newNode.hash,
385
+ },
386
+ signal,
387
+ );
388
+
389
+ return makeNodeUid(volumeId, response.LinkID);
390
+ }
391
+
306
392
  // Improvement requested: split into multiple calls for many nodes.
307
393
  async *trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
308
394
  const nodeIds = nodeUids.map(splitNodeUid);
@@ -53,7 +53,9 @@ export class NodesCache {
53
53
  return deserialiseNode(nodeData);
54
54
  } catch (error: unknown) {
55
55
  await this.removeCorruptedNode({ nodeUid }, error);
56
- throw new Error(`Failed to deserialise node: ${error instanceof Error ? error.message : error}`);
56
+ throw new Error(`Failed to deserialise node: ${error instanceof Error ? error.message : error}`, {
57
+ cause: error,
58
+ });
57
59
  }
58
60
  }
59
61
 
@@ -0,0 +1,145 @@
1
+ import { VERIFICATION_STATUS } from '../../crypto';
2
+ import {
3
+ resultOk,
4
+ resultError,
5
+ Author,
6
+ AnonymousUser,
7
+ ProtonDriveTelemetry,
8
+ Logger,
9
+ MetricsDecryptionErrorField,
10
+ MetricVerificationErrorField,
11
+ } from '../../interface';
12
+ import { getVerificationMessage } from '../errors';
13
+ import { splitNodeUid } from '../uids';
14
+ import {
15
+ EncryptedNode,
16
+ SharesService,
17
+ } from './interface';
18
+
19
+ export class NodesCryptoReporter {
20
+ private logger: Logger;
21
+
22
+ private reportedDecryptionErrors = new Set<string>();
23
+ private reportedVerificationErrors = new Set<string>();
24
+
25
+ constructor(
26
+ private telemetry: ProtonDriveTelemetry,
27
+ private shareService: SharesService,
28
+ ) {
29
+ this.telemetry = telemetry;
30
+ this.logger = telemetry.getLogger('nodes-crypto');
31
+ this.shareService = shareService;
32
+ }
33
+
34
+ async handleClaimedAuthor(
35
+ node: { uid: string; creationTime: Date },
36
+ field: MetricVerificationErrorField,
37
+ signatureType: string,
38
+ verified: VERIFICATION_STATUS,
39
+ verificationErrors?: Error[],
40
+ claimedAuthor?: string,
41
+ notAvailableVerificationKeys = false,
42
+ ): Promise<Author> {
43
+ const author = handleClaimedAuthor(
44
+ signatureType,
45
+ verified,
46
+ verificationErrors,
47
+ claimedAuthor,
48
+ notAvailableVerificationKeys,
49
+ );
50
+ if (!author.ok) {
51
+ void this.reportVerificationError(node, field, verificationErrors, claimedAuthor);
52
+ }
53
+ return author;
54
+ }
55
+
56
+ async reportVerificationError(
57
+ node: { uid: string; creationTime: Date },
58
+ field: MetricVerificationErrorField,
59
+ verificationErrors?: Error[],
60
+ claimedAuthor?: string,
61
+ ) {
62
+ if (this.reportedVerificationErrors.has(node.uid)) {
63
+ return;
64
+ }
65
+ this.reportedVerificationErrors.add(node.uid);
66
+
67
+ const fromBefore2024 = node.creationTime < new Date('2024-01-01');
68
+
69
+ let addressMatchingDefaultShare, volumeType;
70
+ try {
71
+ const { volumeId } = splitNodeUid(node.uid);
72
+ const { email } = await this.shareService.getMyFilesShareMemberEmailKey();
73
+ addressMatchingDefaultShare = claimedAuthor ? claimedAuthor === email : undefined;
74
+ volumeType = await this.shareService.getVolumeMetricContext(volumeId);
75
+ } catch (error: unknown) {
76
+ this.logger.error('Failed to check if claimed author matches default share', error);
77
+ }
78
+
79
+ this.logger.warn(
80
+ `Failed to verify ${field} for node ${node.uid} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`,
81
+ );
82
+
83
+ this.telemetry.recordMetric({
84
+ eventName: 'verificationError',
85
+ volumeType,
86
+ field,
87
+ addressMatchingDefaultShare,
88
+ fromBefore2024,
89
+ error: verificationErrors?.map((e) => e.message).join(', '),
90
+ uid: node.uid,
91
+ });
92
+ }
93
+
94
+ async reportDecryptionError(node: EncryptedNode, field: MetricsDecryptionErrorField, error: unknown) {
95
+ if (this.reportedDecryptionErrors.has(node.uid)) {
96
+ return;
97
+ }
98
+
99
+ const fromBefore2024 = node.creationTime < new Date('2024-01-01');
100
+
101
+ let volumeType;
102
+ try {
103
+ const { volumeId } = splitNodeUid(node.uid);
104
+ volumeType = await this.shareService.getVolumeMetricContext(volumeId);
105
+ } catch (error: unknown) {
106
+ this.logger.error('Failed to get metric context', error);
107
+ }
108
+
109
+ this.logger.error(`Failed to decrypt node ${node.uid} (from before 2024: ${fromBefore2024})`, error);
110
+
111
+ this.telemetry.recordMetric({
112
+ eventName: 'decryptionError',
113
+ volumeType,
114
+ field,
115
+ fromBefore2024,
116
+ error,
117
+ uid: node.uid,
118
+ });
119
+ this.reportedDecryptionErrors.add(node.uid);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * @param signatureType - Must be translated before calling this function.
125
+ */
126
+ function handleClaimedAuthor(
127
+ signatureType: string,
128
+ verified: VERIFICATION_STATUS,
129
+ verificationErrors?: Error[],
130
+ claimedAuthor?: string,
131
+ notAvailableVerificationKeys = false,
132
+ ): Author {
133
+ if (!claimedAuthor && notAvailableVerificationKeys) {
134
+ return resultOk(null as AnonymousUser);
135
+ }
136
+
137
+ if (verified === VERIFICATION_STATUS.SIGNED_AND_VALID) {
138
+ return resultOk(claimedAuthor || (null as AnonymousUser));
139
+ }
140
+
141
+ return resultError({
142
+ claimedAuthor,
143
+ error: getVerificationMessage(verified, verificationErrors, signatureType, notAvailableVerificationKeys),
144
+ });
145
+ }