@protontech/drive-sdk 0.15.0 → 0.15.2
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/crypto/driveCrypto.d.ts +1 -1
- package/dist/crypto/driveCrypto.js +2 -2
- package/dist/crypto/driveCrypto.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/internal/apiService/apiService.d.ts +1 -1
- package/dist/internal/apiService/apiService.js +22 -7
- package/dist/internal/apiService/apiService.js.map +1 -1
- package/dist/internal/apiService/apiService.test.js +13 -0
- package/dist/internal/apiService/apiService.test.js.map +1 -1
- package/dist/internal/errors.js +35 -2
- package/dist/internal/errors.js.map +1 -1
- package/dist/internal/events/apiService.d.ts +4 -2
- package/dist/internal/events/apiService.js +17 -13
- package/dist/internal/events/apiService.js.map +1 -1
- package/dist/internal/events/index.d.ts +12 -1
- package/dist/internal/events/index.js +17 -1
- package/dist/internal/events/index.js.map +1 -1
- package/dist/internal/events/index.test.d.ts +1 -0
- package/dist/internal/events/index.test.js +58 -0
- package/dist/internal/events/index.test.js.map +1 -0
- package/dist/internal/nodes/cryptoService.d.ts +1 -0
- package/dist/internal/nodes/cryptoService.js +4 -0
- package/dist/internal/nodes/cryptoService.js.map +1 -1
- package/dist/internal/nodes/nodesAccess.test.js +2 -2
- package/dist/internal/nodes/nodesAccess.test.js.map +1 -1
- package/dist/internal/nodes/nodesManagement.js +2 -2
- package/dist/internal/nodes/nodesManagement.js.map +1 -1
- package/dist/internal/nodes/nodesManagement.test.js +1 -1
- package/dist/internal/nodes/nodesManagement.test.js.map +1 -1
- package/dist/internal/nodes/nodesRevisions.d.ts +1 -1
- package/dist/internal/nodes/nodesRevisions.js +3 -0
- package/dist/internal/nodes/nodesRevisions.js.map +1 -1
- package/dist/internal/nodes/validations.d.ts +1 -1
- package/dist/internal/nodes/validations.js +1 -4
- package/dist/internal/nodes/validations.js.map +1 -1
- package/dist/internal/photos/addToAlbum.js +1 -1
- package/dist/internal/photos/addToAlbum.js.map +1 -1
- package/dist/internal/photos/addToAlbum.test.js +12 -12
- package/dist/internal/photos/addToAlbum.test.js.map +1 -1
- package/dist/internal/photos/albumsManager.js +1 -1
- package/dist/internal/photos/albumsManager.js.map +1 -1
- package/dist/internal/photos/albumsManager.test.js +2 -2
- package/dist/internal/photos/albumsManager.test.js.map +1 -1
- package/dist/internal/photos/apiService.d.ts +3 -3
- package/dist/internal/photos/apiService.js +5 -5
- package/dist/internal/photos/apiService.js.map +1 -1
- package/dist/internal/photos/apiService.test.js +4 -4
- package/dist/internal/photos/apiService.test.js.map +1 -1
- package/dist/internal/photos/photosManager.d.ts +1 -0
- package/dist/internal/photos/photosManager.js +38 -2
- package/dist/internal/photos/photosManager.js.map +1 -1
- package/dist/internal/photos/photosManager.test.js +26 -0
- package/dist/internal/photos/photosManager.test.js.map +1 -1
- package/dist/internal/sharing/cryptoService.js +4 -3
- package/dist/internal/sharing/cryptoService.js.map +1 -1
- package/dist/internal/sharing/cryptoService.test.js +3 -3
- package/dist/internal/sharing/cryptoService.test.js.map +1 -1
- package/dist/internal/sharingPublic/nodes.d.ts +1 -0
- package/dist/internal/sharingPublic/nodes.js +2 -0
- package/dist/internal/sharingPublic/nodes.js.map +1 -1
- package/dist/protonDriveClient.d.ts +14 -2
- package/dist/protonDriveClient.js +6 -0
- package/dist/protonDriveClient.js.map +1 -1
- package/dist/protonDrivePhotosClient.d.ts +10 -2
- package/dist/protonDrivePhotosClient.js +6 -0
- package/dist/protonDrivePhotosClient.js.map +1 -1
- package/package.json +1 -1
- package/src/crypto/driveCrypto.ts +2 -1
- package/src/index.ts +1 -1
- package/src/internal/apiService/apiService.test.ts +16 -0
- package/src/internal/apiService/apiService.ts +20 -2
- package/src/internal/errors.ts +40 -1
- package/src/internal/events/apiService.ts +20 -17
- package/src/internal/events/index.test.ts +67 -0
- package/src/internal/events/index.ts +20 -2
- package/src/internal/nodes/cryptoService.ts +6 -0
- package/src/internal/nodes/nodesAccess.test.ts +2 -2
- package/src/internal/nodes/nodesManagement.test.ts +1 -1
- package/src/internal/nodes/nodesManagement.ts +2 -2
- package/src/internal/nodes/nodesRevisions.ts +5 -1
- package/src/internal/nodes/validations.ts +1 -4
- package/src/internal/photos/addToAlbum.test.ts +12 -12
- package/src/internal/photos/addToAlbum.ts +1 -1
- package/src/internal/photos/albumsManager.test.ts +2 -2
- package/src/internal/photos/albumsManager.ts +1 -1
- package/src/internal/photos/apiService.test.ts +4 -4
- package/src/internal/photos/apiService.ts +6 -6
- package/src/internal/photos/photosManager.test.ts +36 -1
- package/src/internal/photos/photosManager.ts +48 -7
- package/src/internal/sharing/cryptoService.test.ts +3 -3
- package/src/internal/sharing/cryptoService.ts +4 -3
- package/src/internal/sharingPublic/nodes.ts +3 -0
- package/src/protonDriveClient.ts +18 -1
- package/src/protonDrivePhotosClient.ts +15 -5
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ export { OpenPGPCryptoWithCryptoProxy } from './crypto';
|
|
|
10
10
|
export * from './errors';
|
|
11
11
|
export { NullFeatureFlagProvider } from './featureFlags';
|
|
12
12
|
export * from './interface';
|
|
13
|
-
export type { EventSubscription } from './internal/events';
|
|
13
|
+
export type { CoreApiEvent, EventSubscription } from './internal/events';
|
|
14
14
|
export { ProtonDriveClient } from './protonDriveClient';
|
|
15
15
|
export { VERSION } from './version';
|
|
16
16
|
|
|
@@ -223,6 +223,22 @@ describe('DriveAPIService', () => {
|
|
|
223
223
|
expect(telemetry.recordMetric).not.toHaveBeenCalled();
|
|
224
224
|
});
|
|
225
225
|
|
|
226
|
+
it('on transient socket / transport error', async () => {
|
|
227
|
+
const error = new Error('The socket connection was closed unexpectedly');
|
|
228
|
+
httpClient.fetchJson = jest
|
|
229
|
+
.fn()
|
|
230
|
+
.mockRejectedValueOnce(error)
|
|
231
|
+
.mockRejectedValueOnce(error)
|
|
232
|
+
.mockResolvedValueOnce(generateOkResponse());
|
|
233
|
+
|
|
234
|
+
const result = api.get('test');
|
|
235
|
+
|
|
236
|
+
await expect(result).resolves.toEqual({ Code: ErrorCode.OK });
|
|
237
|
+
expect(httpClient.fetchJson).toHaveBeenCalledTimes(3);
|
|
238
|
+
expectSDKEvents();
|
|
239
|
+
expect(telemetry.recordMetric).not.toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
|
|
226
242
|
it('on general error', async () => {
|
|
227
243
|
const error = new Error('Error');
|
|
228
244
|
httpClient.fetchJson = jest.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(generateOkResponse());
|
|
@@ -3,6 +3,7 @@ import { c } from 'ttag';
|
|
|
3
3
|
import { AbortError, ProtonDriveError, RateLimitedError, ServerError } from '../../errors';
|
|
4
4
|
import { Logger, ProtonDriveHTTPClient, ProtonDriveTelemetry } from '../../interface';
|
|
5
5
|
import { VERSION } from '../../version';
|
|
6
|
+
import { isNetworkError } from '../errors';
|
|
6
7
|
import { SDKEvents } from '../sdkEvents';
|
|
7
8
|
import { waitSeconds } from '../wait';
|
|
8
9
|
import { HTTPErrorCode, isCodeOk, isCodeOkAsync } from './errorCodes';
|
|
@@ -23,6 +24,11 @@ const DEFAULT_STORAGE_TIMEOUT_MS = 600_000;
|
|
|
23
24
|
*/
|
|
24
25
|
const MAX_TIMEOUT_ERROR_RETRY_ATTEMPTS = 3;
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Maximum number of retry attempts for a network error.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_NETWORK_ERROR_RETRY_ATTEMPTS = 3;
|
|
31
|
+
|
|
26
32
|
/**
|
|
27
33
|
* How many subsequent 429 errors are allowed before we stop further requests.
|
|
28
34
|
*/
|
|
@@ -55,6 +61,11 @@ const TOO_MANY_SUBSEQUENT_OFFLINE_ERRORS = 10;
|
|
|
55
61
|
*/
|
|
56
62
|
const SERVER_ERROR_RETRY_DELAY_SECONDS = 1;
|
|
57
63
|
|
|
64
|
+
/**
|
|
65
|
+
* After how long to re-try after network error.
|
|
66
|
+
*/
|
|
67
|
+
const NETWORK_ERROR_RETRY_DELAY_SECONDS = 5;
|
|
68
|
+
|
|
58
69
|
/**
|
|
59
70
|
* After how long to re-try after offline error.
|
|
60
71
|
*/
|
|
@@ -80,7 +91,7 @@ const GENERAL_RETRY_DELAY_SECONDS = 1;
|
|
|
80
91
|
*
|
|
81
92
|
* * exception from HTTP client
|
|
82
93
|
* * retry on offline exc. (with delay from OFFLINE_RETRY_DELAY_SECONDS)
|
|
83
|
-
* * retry on
|
|
94
|
+
* * retry on transient network exc. (with delay from SERVER_ERROR_RETRY_DELAY_SECONDS)
|
|
84
95
|
* * retry ONCE on any exc. (with delay from GENERAL_RETRY_DELAY_SECONDS)
|
|
85
96
|
* * HTTP status 429
|
|
86
97
|
* * retry (with delay from `retry-after` header or DEFAULT_429_RETRY_DELAY_SECONDS)
|
|
@@ -318,6 +329,12 @@ export class DriveAPIService {
|
|
|
318
329
|
await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS);
|
|
319
330
|
return this.fetch(request, callback, { attempt: attempt + 1, previousError: error });
|
|
320
331
|
}
|
|
332
|
+
|
|
333
|
+
if (isNetworkError(error) && attempt + 1 < MAX_NETWORK_ERROR_RETRY_ATTEMPTS) {
|
|
334
|
+
this.logger.warn(`${request.method} ${request.url}: Network error, retrying`);
|
|
335
|
+
await waitSeconds(NETWORK_ERROR_RETRY_DELAY_SECONDS);
|
|
336
|
+
return this.fetch(request, callback, { attempt: attempt + 1, previousError: error });
|
|
337
|
+
}
|
|
321
338
|
}
|
|
322
339
|
if (attempt === 0) {
|
|
323
340
|
this.logger.error(`${request.method} ${request.url}: failed, retrying once`, error);
|
|
@@ -367,7 +384,8 @@ export class DriveAPIService {
|
|
|
367
384
|
!(previousError instanceof Error) ||
|
|
368
385
|
(previousError instanceof Error &&
|
|
369
386
|
previousError.name !== 'TimeoutError' &&
|
|
370
|
-
previousError.name !== 'OfflineError'
|
|
387
|
+
previousError.name !== 'OfflineError' &&
|
|
388
|
+
!isNetworkError(previousError));
|
|
371
389
|
|
|
372
390
|
if (isWarning) {
|
|
373
391
|
this.telemetry.recordMetric({
|
package/src/internal/errors.ts
CHANGED
|
@@ -72,7 +72,7 @@ export function isNetworkError(error: unknown): boolean {
|
|
|
72
72
|
if (!(error instanceof Error)) {
|
|
73
73
|
return false;
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
if (
|
|
76
76
|
error.name === 'OfflineError' ||
|
|
77
77
|
error.name === 'NetworkError' ||
|
|
78
78
|
error.message?.toLowerCase() === 'network error' ||
|
|
@@ -80,5 +80,44 @@ export function isNetworkError(error: unknown): boolean {
|
|
|
80
80
|
['Failed to fetch', 'NetworkError when attempting to fetch resource', 'Load failed'].includes(
|
|
81
81
|
error.message,
|
|
82
82
|
))
|
|
83
|
+
) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (errorMessageIndicatesTransientTransportFailure(error.message) || errorHasTransientTransportCode(error)) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
if (error.cause instanceof Error) {
|
|
90
|
+
return (
|
|
91
|
+
errorMessageIndicatesTransientTransportFailure(error.cause.message) ||
|
|
92
|
+
errorHasTransientTransportCode(error.cause)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function errorMessageIndicatesTransientTransportFailure(message: string | undefined): boolean {
|
|
99
|
+
if (!message) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const lower = message.toLowerCase();
|
|
103
|
+
return (
|
|
104
|
+
// Remote end closed TLS/TCP without a complete response.
|
|
105
|
+
lower.includes('socket connection was closed unexpectedly') ||
|
|
106
|
+
// Remote end sent RST or closed the write side mid-request.
|
|
107
|
+
lower.includes('other side closed') ||
|
|
108
|
+
// Remote end closed the socket abruptly.
|
|
109
|
+
lower.includes('socket hang up')
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function errorHasTransientTransportCode(error: Error): boolean {
|
|
114
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
115
|
+
return (
|
|
116
|
+
// TCP RST or equivalent: common under flaky networks or after server restart.
|
|
117
|
+
code === 'ECONNRESET' ||
|
|
118
|
+
// Writing to a socket whose other end is gone (often grouped with reset/hang-up).
|
|
119
|
+
code === 'EPIPE' ||
|
|
120
|
+
// Socket-level failure after connect (e.g. unexpected close on the wire).
|
|
121
|
+
code === 'UND_ERR_SOCKET'
|
|
83
122
|
);
|
|
84
123
|
}
|
|
@@ -4,7 +4,7 @@ import { DriveEvent, DriveEventsListWithStatus, DriveEventType, NodeEvent, NodeE
|
|
|
4
4
|
|
|
5
5
|
type GetCoreLatestEventResponse =
|
|
6
6
|
corePaths['/core/{_version}/events/latest']['get']['responses']['200']['content']['application/json'];
|
|
7
|
-
type
|
|
7
|
+
export type CoreApiEvent =
|
|
8
8
|
corePaths['/core/{_version}/events/{id}']['get']['responses']['200']['content']['application/json'];
|
|
9
9
|
|
|
10
10
|
type GetVolumeLatestEventResponse =
|
|
@@ -40,28 +40,33 @@ export class EventsAPIService {
|
|
|
40
40
|
|
|
41
41
|
async getCoreEvents(eventId: string): Promise<DriveEventsListWithStatus> {
|
|
42
42
|
// TODO: Switch to v6 endpoint?
|
|
43
|
-
const result = await this.apiService.get<
|
|
43
|
+
const result = await this.apiService.get<CoreApiEvent>(`core/v5/events/${eventId}`);
|
|
44
|
+
const driveEvents = EventsAPIService.getDriveEventsFromCoreEvent(result);
|
|
44
45
|
// in core/v5/events, refresh is always all apps, value 255
|
|
45
46
|
const refresh = result.Refresh > 0;
|
|
46
|
-
const events: DriveEvent[] =
|
|
47
|
-
refresh || result.DriveShareRefresh?.Action === 2
|
|
48
|
-
? [
|
|
49
|
-
{
|
|
50
|
-
type: DriveEventType.SharedWithMeUpdated,
|
|
51
|
-
eventId: result.EventID,
|
|
52
|
-
treeEventScopeId: 'core',
|
|
53
|
-
},
|
|
54
|
-
]
|
|
55
|
-
: [];
|
|
56
|
-
|
|
57
47
|
return {
|
|
58
48
|
latestEventId: result.EventID,
|
|
59
49
|
more: result.More === 1,
|
|
60
50
|
refresh,
|
|
61
|
-
events,
|
|
51
|
+
events: driveEvents,
|
|
62
52
|
};
|
|
63
53
|
}
|
|
64
54
|
|
|
55
|
+
static getDriveEventsFromCoreEvent(result: CoreApiEvent): DriveEvent[] {
|
|
56
|
+
// in core/v5/events, refresh is always all apps, value 255
|
|
57
|
+
const refresh = result.Refresh > 0;
|
|
58
|
+
if (refresh || result.DriveShareRefresh?.Action === 2) {
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
type: DriveEventType.SharedWithMeUpdated,
|
|
62
|
+
eventId: result.EventID,
|
|
63
|
+
treeEventScopeId: 'core',
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
65
70
|
async getVolumeLatestEventId(volumeId: string): Promise<string> {
|
|
66
71
|
const result = await this.apiService.get<GetVolumeLatestEventResponse>(
|
|
67
72
|
`drive/volumes/${volumeId}/events/latest`,
|
|
@@ -81,9 +86,7 @@ export class EventsAPIService {
|
|
|
81
86
|
const type = VOLUME_EVENT_TYPE_MAP[event.EventType];
|
|
82
87
|
const uids = {
|
|
83
88
|
nodeUid: makeNodeUid(volumeId, event.Link.LinkID),
|
|
84
|
-
parentNodeUid: event.Link.ParentLinkID
|
|
85
|
-
? makeNodeUid(volumeId, event.Link.ParentLinkID)
|
|
86
|
-
: undefined,
|
|
89
|
+
parentNodeUid: event.Link.ParentLinkID ? makeNodeUid(volumeId, event.Link.ParentLinkID) : undefined,
|
|
87
90
|
};
|
|
88
91
|
return {
|
|
89
92
|
type,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { getMockTelemetry } from '../../tests/telemetry';
|
|
2
|
+
import { DriveAPIService } from '../apiService';
|
|
3
|
+
import { CoreApiEvent } from './apiService';
|
|
4
|
+
import { DriveEventsService } from './index';
|
|
5
|
+
import { DriveEventType, DriveListener } from './interface';
|
|
6
|
+
|
|
7
|
+
describe('DriveEventsService', () => {
|
|
8
|
+
describe('processCoreEvent', () => {
|
|
9
|
+
function createService(cacheEventListeners: DriveListener[] = []) {
|
|
10
|
+
const telemetry = getMockTelemetry();
|
|
11
|
+
const apiService = {} as unknown as DriveAPIService;
|
|
12
|
+
const sharesService = { isOwnVolume: jest.fn() };
|
|
13
|
+
return new DriveEventsService(telemetry, apiService, sharesService, cacheEventListeners);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
it('returns no drive events and does not notify listeners when the raw event is not a refresh', async () => {
|
|
17
|
+
const listener: jest.MockedFunction<DriveListener> = jest.fn().mockResolvedValue(undefined);
|
|
18
|
+
const service = createService([listener]);
|
|
19
|
+
const raw = {
|
|
20
|
+
EventID: 'event-no-refresh',
|
|
21
|
+
Refresh: 0,
|
|
22
|
+
} as CoreApiEvent;
|
|
23
|
+
|
|
24
|
+
const result = await service.processCoreEvent(raw);
|
|
25
|
+
|
|
26
|
+
expect(result).toEqual([]);
|
|
27
|
+
expect(listener).not.toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns SharedWithMeUpdated when Refresh is non-zero', async () => {
|
|
31
|
+
const service = createService();
|
|
32
|
+
const raw = {
|
|
33
|
+
EventID: 'event-refresh',
|
|
34
|
+
Refresh: 255,
|
|
35
|
+
} as CoreApiEvent;
|
|
36
|
+
|
|
37
|
+
const result = await service.processCoreEvent(raw);
|
|
38
|
+
|
|
39
|
+
expect(result).toEqual([
|
|
40
|
+
{
|
|
41
|
+
type: DriveEventType.SharedWithMeUpdated,
|
|
42
|
+
eventId: 'event-refresh',
|
|
43
|
+
treeEventScopeId: 'core',
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns SharedWithMeUpdated when DriveShareRefresh.Action is 2', async () => {
|
|
49
|
+
const service = createService();
|
|
50
|
+
const raw = {
|
|
51
|
+
EventID: 'event-share-refresh',
|
|
52
|
+
Refresh: 0,
|
|
53
|
+
DriveShareRefresh: { Action: 2 },
|
|
54
|
+
} as CoreApiEvent;
|
|
55
|
+
|
|
56
|
+
const result = await service.processCoreEvent(raw);
|
|
57
|
+
|
|
58
|
+
expect(result).toEqual([
|
|
59
|
+
{
|
|
60
|
+
type: DriveEventType.SharedWithMeUpdated,
|
|
61
|
+
eventId: 'event-share-refresh',
|
|
62
|
+
treeEventScopeId: 'core',
|
|
63
|
+
},
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Logger, ProtonDriveTelemetry } from '../../interface';
|
|
2
2
|
import { DriveAPIService } from '../apiService';
|
|
3
|
-
import { EventsAPIService } from './apiService';
|
|
3
|
+
import { CoreApiEvent, EventsAPIService } from './apiService';
|
|
4
4
|
import { CoreEventManager } from './coreEventManager';
|
|
5
5
|
import { EventManager } from './eventManager';
|
|
6
6
|
import { DriveEvent, DriveListener, EventSubscription, LatestEventIdProvider, SharesService } from './interface';
|
|
7
7
|
import { VolumeEventManager } from './volumeEventManager';
|
|
8
8
|
|
|
9
|
+
export type { CoreApiEvent } from './apiService';
|
|
9
10
|
export type { DriveEvent, DriveListener, EventSubscription } from './interface';
|
|
10
11
|
export { DriveEventType } from './interface';
|
|
11
12
|
|
|
@@ -37,7 +38,9 @@ export class DriveEventsService {
|
|
|
37
38
|
this.volumeEventManagers = {};
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
/**
|
|
42
|
+
* @deprecated Use `processCoreEvent` instead.
|
|
43
|
+
*/
|
|
41
44
|
async subscribeToCoreEvents(callback: DriveListener): Promise<EventSubscription> {
|
|
42
45
|
let manager = this.coreEventManager;
|
|
43
46
|
const started = !!manager;
|
|
@@ -72,6 +75,21 @@ export class DriveEventsService {
|
|
|
72
75
|
return eventManager;
|
|
73
76
|
}
|
|
74
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Process a raw core API event fetched by the caller's own event loop.
|
|
80
|
+
* The SDK derives drive-relevant events from it, updates internal caches,
|
|
81
|
+
* and notifies all listeners registered via `subscribeToPushedCoreEvents`.
|
|
82
|
+
*/
|
|
83
|
+
async processCoreEvent(rawEvent: CoreApiEvent): Promise<DriveEvent[]> {
|
|
84
|
+
const driveEvents = EventsAPIService.getDriveEventsFromCoreEvent(rawEvent);
|
|
85
|
+
for (const event of driveEvents) {
|
|
86
|
+
for (const listener of this.cacheEventListeners) {
|
|
87
|
+
await listener(event);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return driveEvents;
|
|
91
|
+
}
|
|
92
|
+
|
|
75
93
|
/**
|
|
76
94
|
* Subscribe to drive events. The treeEventScopeId can be obtained from a node.
|
|
77
95
|
*/
|
|
@@ -73,6 +73,8 @@ type NodesCryptoReporterNode = {
|
|
|
73
73
|
export class NodesCryptoService {
|
|
74
74
|
private logger: Logger;
|
|
75
75
|
|
|
76
|
+
protected allowContentKeyPacketFallbackVerification = true;
|
|
77
|
+
|
|
76
78
|
constructor(
|
|
77
79
|
telemetry: ProtonDriveTelemetry,
|
|
78
80
|
protected driveCrypto: DriveCrypto,
|
|
@@ -541,6 +543,10 @@ export class NodesCryptoService {
|
|
|
541
543
|
return result;
|
|
542
544
|
}
|
|
543
545
|
|
|
546
|
+
if (!this.allowContentKeyPacketFallbackVerification) {
|
|
547
|
+
return result;
|
|
548
|
+
}
|
|
549
|
+
|
|
544
550
|
const { volumeId: ownVolumeId } = await this.sharesService.getRootIDs();
|
|
545
551
|
const { volumeId: nodesVolumeId } = splitNodeUid(node.uid);
|
|
546
552
|
|
|
@@ -134,11 +134,11 @@ describe('nodesAccess', () => {
|
|
|
134
134
|
const decryptedUnparsedNode = {
|
|
135
135
|
uid: 'volumeId~nodeId',
|
|
136
136
|
parentUid: 'volumeId~parentNodeid',
|
|
137
|
-
name: { ok: true, value: '
|
|
137
|
+
name: { ok: true, value: '' },
|
|
138
138
|
} as DecryptedUnparsedNode;
|
|
139
139
|
const decryptedNode = {
|
|
140
140
|
...decryptedUnparsedNode,
|
|
141
|
-
name: { ok: false, error: { name: '
|
|
141
|
+
name: { ok: false, error: { name: '', error: "Name must not be empty" } },
|
|
142
142
|
treeEventScopeId: 'volumeId',
|
|
143
143
|
} as DecryptedNode;
|
|
144
144
|
const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys;
|
|
@@ -426,7 +426,7 @@ describe('NodesManagement', () => {
|
|
|
426
426
|
});
|
|
427
427
|
|
|
428
428
|
it('copyNode throws error if name is invalid', async () => {
|
|
429
|
-
const promise = management.copyNode('nodeUid', 'newParentNodeUid', '
|
|
429
|
+
const promise = management.copyNode('nodeUid', 'newParentNodeUid', '');
|
|
430
430
|
await expect(promise).rejects.toThrow(ValidationError);
|
|
431
431
|
});
|
|
432
432
|
|
|
@@ -236,12 +236,12 @@ export abstract class NodesManagementBase<
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
async copyNode(nodeUid: string, newParentUid: string, name?: string): Promise<TDecryptedNode> {
|
|
239
|
-
if (name) {
|
|
239
|
+
if (name !== undefined) {
|
|
240
240
|
validateNodeName(name);
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
const node = await this.nodesAccess.getNode(nodeUid);
|
|
244
|
-
const nodeName = name ? resultOk<string, Error | InvalidNameError>(name) : node.name;
|
|
244
|
+
const nodeName = name !== undefined ? resultOk<string, Error | InvalidNameError>(name) : node.name;
|
|
245
245
|
|
|
246
246
|
const [keys, newParentKeys, signingKeys] = await Promise.all([
|
|
247
247
|
this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid),
|
|
@@ -14,7 +14,7 @@ export class NodesRevisons {
|
|
|
14
14
|
private logger: Logger,
|
|
15
15
|
private apiService: NodeAPIServiceBase,
|
|
16
16
|
private cryptoService: NodesCryptoService,
|
|
17
|
-
private nodesAccess: Pick<NodesAccess, 'getNodeKeys'>,
|
|
17
|
+
private nodesAccess: Pick<NodesAccess, 'getNodeKeys' | 'notifyNodeChanged'>,
|
|
18
18
|
) {
|
|
19
19
|
this.logger = logger;
|
|
20
20
|
this.apiService = apiService;
|
|
@@ -67,6 +67,10 @@ export class NodesRevisons {
|
|
|
67
67
|
|
|
68
68
|
async restoreRevision(nodeRevisionUid: string): Promise<void> {
|
|
69
69
|
await this.apiService.restoreRevision(nodeRevisionUid);
|
|
70
|
+
|
|
71
|
+
// Restoring a revision creates a new active revision.
|
|
72
|
+
const nodeUid = makeNodeUidFromRevisionUid(nodeRevisionUid);
|
|
73
|
+
await this.nodesAccess.notifyNodeChanged(nodeUid);
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
async deleteRevision(nodeRevisionUid: string): Promise<void> {
|
|
@@ -5,7 +5,7 @@ import { ValidationError } from '../../errors';
|
|
|
5
5
|
const MAX_NODE_NAME_LENGTH = 255;
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* @throws Error if the name is empty
|
|
8
|
+
* @throws Error if the name is empty or long.
|
|
9
9
|
*/
|
|
10
10
|
export function validateNodeName(name: string): void {
|
|
11
11
|
if (!name) {
|
|
@@ -20,7 +20,4 @@ export function validateNodeName(name: string): void {
|
|
|
20
20
|
),
|
|
21
21
|
);
|
|
22
22
|
}
|
|
23
|
-
if (name.includes('/')) {
|
|
24
|
-
throw new ValidationError(c('Error').t`Name must not contain the character '/'`);
|
|
25
|
-
}
|
|
26
23
|
}
|
|
@@ -69,7 +69,7 @@ describe('AddToAlbumProcess', () => {
|
|
|
69
69
|
// @ts-expect-error Mocking for testing purposes
|
|
70
70
|
apiService = {
|
|
71
71
|
addPhotosToAlbum: jest.fn(),
|
|
72
|
-
|
|
72
|
+
copyPhoto: jest.fn(),
|
|
73
73
|
};
|
|
74
74
|
|
|
75
75
|
// @ts-expect-error Mocking for testing purposes
|
|
@@ -154,7 +154,7 @@ describe('AddToAlbumProcess', () => {
|
|
|
154
154
|
});
|
|
155
155
|
|
|
156
156
|
let copyToAlbumReturnedMissing = false;
|
|
157
|
-
apiService.
|
|
157
|
+
apiService.copyPhoto.mockImplementation(async (albumUid, payload) => {
|
|
158
158
|
let error: Error | undefined;
|
|
159
159
|
if (payload.nodeUid.includes('missingRelatedTwice')) {
|
|
160
160
|
error = new MissingRelatedPhotosError(['volume2~missingRelatedTwice1']);
|
|
@@ -322,7 +322,7 @@ describe('AddToAlbumProcess', () => {
|
|
|
322
322
|
const photoUids = Array.from({ length: 25 }, (_, i) => `volume2~photo${i}`);
|
|
323
323
|
|
|
324
324
|
let copyPhotoCallCount = 0;
|
|
325
|
-
apiService.
|
|
325
|
+
apiService.copyPhoto.mockImplementation(async (albumUid, payload) => {
|
|
326
326
|
copyPhotoCallCount++;
|
|
327
327
|
|
|
328
328
|
// First few calls should happen before all 25 photos are prepared
|
|
@@ -351,8 +351,8 @@ describe('AddToAlbumProcess', () => {
|
|
|
351
351
|
uid: mainPhotoUid,
|
|
352
352
|
ok: true,
|
|
353
353
|
}])
|
|
354
|
-
expect(apiService.
|
|
355
|
-
const params = apiService.
|
|
354
|
+
expect(apiService.copyPhoto).toHaveBeenCalledTimes(1);
|
|
355
|
+
const params = apiService.copyPhoto.mock.calls[0];
|
|
356
356
|
expect(params[1].relatedPhotos?.length).toBe(15);
|
|
357
357
|
});
|
|
358
358
|
|
|
@@ -366,7 +366,7 @@ describe('AddToAlbumProcess', () => {
|
|
|
366
366
|
ok: true,
|
|
367
367
|
}]);
|
|
368
368
|
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo
|
|
369
|
-
expect(apiService.
|
|
369
|
+
expect(apiService.copyPhoto).toHaveBeenCalledTimes(2); // two attempts
|
|
370
370
|
});
|
|
371
371
|
|
|
372
372
|
it('should return error if missing related photos error occurs twice', async () => {
|
|
@@ -380,7 +380,7 @@ describe('AddToAlbumProcess', () => {
|
|
|
380
380
|
error: new MissingRelatedPhotosError(['volume2~missingRelatedOnce1']),
|
|
381
381
|
}]);
|
|
382
382
|
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo
|
|
383
|
-
expect(apiService.
|
|
383
|
+
expect(apiService.copyPhoto).toHaveBeenCalledTimes(2); // two attempts
|
|
384
384
|
});
|
|
385
385
|
|
|
386
386
|
it('should return error when crypto service fails', async () => {
|
|
@@ -428,7 +428,7 @@ describe('AddToAlbumProcess', () => {
|
|
|
428
428
|
it('should not notify for failed photo copies', async () => {
|
|
429
429
|
const photoUid = 'volume2~photo1';
|
|
430
430
|
|
|
431
|
-
apiService.
|
|
431
|
+
apiService.copyPhoto.mockRejectedValue(new Error('API error'));
|
|
432
432
|
|
|
433
433
|
const results = await executeProcess([photoUid]);
|
|
434
434
|
|
|
@@ -467,9 +467,9 @@ describe('AddToAlbumProcess', () => {
|
|
|
467
467
|
expect(nodesService.iterateNodes.mock.calls[1][0]).toMatchObject(differentVolumeUids);
|
|
468
468
|
expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(1);
|
|
469
469
|
expect(apiService.addPhotosToAlbum.mock.calls[0][1].map(({ nodeUid }) => nodeUid)).toMatchObject(sameVolumeUids);
|
|
470
|
-
expect(apiService.
|
|
471
|
-
expect(apiService.
|
|
472
|
-
expect(apiService.
|
|
470
|
+
expect(apiService.copyPhoto).toHaveBeenCalledTimes(2);
|
|
471
|
+
expect(apiService.copyPhoto.mock.calls[0][1].nodeUid).toBe(differentVolumeUids[0]);
|
|
472
|
+
expect(apiService.copyPhoto.mock.calls[1][1].nodeUid).toBe(differentVolumeUids[1]);
|
|
473
473
|
});
|
|
474
474
|
|
|
475
475
|
it('should prepare payloads in parallel for both queues', async () => {
|
|
@@ -496,7 +496,7 @@ describe('AddToAlbumProcess', () => {
|
|
|
496
496
|
expect(results[1].ok).toBe(true);
|
|
497
497
|
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3 + 3); // main photo + related photo + missing related photo
|
|
498
498
|
expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(2); // two attempts
|
|
499
|
-
expect(apiService.
|
|
499
|
+
expect(apiService.copyPhoto).toHaveBeenCalledTimes(2); // two attempts
|
|
500
500
|
});
|
|
501
501
|
|
|
502
502
|
it('should notify correctly for both volumes', async () => {
|
|
@@ -118,7 +118,7 @@ export class AddToAlbumProcess {
|
|
|
118
118
|
|
|
119
119
|
for (const payload of payloads) {
|
|
120
120
|
try {
|
|
121
|
-
const newPhotoNodeUid = await this.apiService.
|
|
121
|
+
const newPhotoNodeUid = await this.apiService.copyPhoto(
|
|
122
122
|
this.albumNodeUid,
|
|
123
123
|
payload,
|
|
124
124
|
this.signal,
|
|
@@ -152,7 +152,7 @@ describe('Albums', () => {
|
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
it('throws validation error for invalid album name', async () => {
|
|
155
|
-
await expect(albums.createAlbum('
|
|
155
|
+
await expect(albums.createAlbum('')).rejects.toThrow(ValidationError);
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
it('throws error when parent hash key is not available', async () => {
|
|
@@ -228,7 +228,7 @@ describe('Albums', () => {
|
|
|
228
228
|
});
|
|
229
229
|
|
|
230
230
|
it('throws validation error for invalid album name', async () => {
|
|
231
|
-
await expect(albums.updateAlbum('albumNodeUid', { name: '
|
|
231
|
+
await expect(albums.updateAlbum('albumNodeUid', { name: '' })).rejects.toThrow(ValidationError);
|
|
232
232
|
});
|
|
233
233
|
});
|
|
234
234
|
|
|
@@ -147,7 +147,7 @@ describe('photosAPIService', () => {
|
|
|
147
147
|
});
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
-
describe('
|
|
150
|
+
describe('copyPhoto', () => {
|
|
151
151
|
const photoPayloads = [
|
|
152
152
|
{
|
|
153
153
|
nodeUid: 'volumeId2~photoNodeId1',
|
|
@@ -181,7 +181,7 @@ describe('photosAPIService', () => {
|
|
|
181
181
|
LinkID: 'photoNodeId1',
|
|
182
182
|
});
|
|
183
183
|
|
|
184
|
-
const result = await api.
|
|
184
|
+
const result = await api.copyPhoto(albumNodeUid, photoPayloads[0]);
|
|
185
185
|
|
|
186
186
|
expect(result).toEqual('volumeId1~photoNodeId1');
|
|
187
187
|
expect(apiMock.post).toHaveBeenCalledWith(
|
|
@@ -215,7 +215,7 @@ describe('photosAPIService', () => {
|
|
|
215
215
|
},
|
|
216
216
|
));
|
|
217
217
|
|
|
218
|
-
const promise = api.
|
|
218
|
+
const promise = api.copyPhoto(albumNodeUid, photoPayloads[0]);
|
|
219
219
|
|
|
220
220
|
await expect(promise).rejects.toThrow(MissingRelatedPhotosError);
|
|
221
221
|
try {
|
|
@@ -229,7 +229,7 @@ describe('photosAPIService', () => {
|
|
|
229
229
|
const error = new APICodeError('Some error', 3000);
|
|
230
230
|
apiMock.post = jest.fn().mockRejectedValue(error);
|
|
231
231
|
|
|
232
|
-
const promise = api.
|
|
232
|
+
const promise = api.copyPhoto(albumNodeUid, photoPayloads[0]);
|
|
233
233
|
|
|
234
234
|
await expect(promise).rejects.toThrow(error);
|
|
235
235
|
});
|
|
@@ -358,7 +358,7 @@ export class PhotosAPIService {
|
|
|
358
358
|
/**
|
|
359
359
|
* Add photos from the same volume to an album.
|
|
360
360
|
*
|
|
361
|
-
* To add photos from different volumes, use the {@link
|
|
361
|
+
* To add photos from different volumes, use the {@link copyPhoto} method.
|
|
362
362
|
*
|
|
363
363
|
* In the future, these two methods will be merged into a single one.
|
|
364
364
|
*/
|
|
@@ -432,26 +432,26 @@ export class PhotosAPIService {
|
|
|
432
432
|
}
|
|
433
433
|
|
|
434
434
|
/**
|
|
435
|
-
* Copy a photo
|
|
435
|
+
* Copy a photo from a different volume to an album or to the user's own timeline root.
|
|
436
436
|
*
|
|
437
437
|
* To add photos from the same volume to an album, use the {@link addPhotosToAlbum} method.
|
|
438
438
|
*
|
|
439
439
|
* In the future, these two methods will be merged into a single one.
|
|
440
440
|
*/
|
|
441
|
-
async
|
|
442
|
-
|
|
441
|
+
async copyPhoto(
|
|
442
|
+
targetNodeUid: string,
|
|
443
443
|
payload: TransferEncryptedPhotoPayload,
|
|
444
444
|
signal?: AbortSignal,
|
|
445
445
|
): Promise<string> {
|
|
446
446
|
const { volumeId: sourceVolumeId, nodeId: sourceLinkId } = splitNodeUid(payload.nodeUid);
|
|
447
|
-
const { volumeId: targetVolumeId, nodeId:
|
|
447
|
+
const { volumeId: targetVolumeId, nodeId: targetNodeId } = splitNodeUid(targetNodeUid);
|
|
448
448
|
|
|
449
449
|
try {
|
|
450
450
|
const response = await this.apiService.post<PostCopyLinkRequest, PostCopyLinkResponse>(
|
|
451
451
|
`drive/volumes/${sourceVolumeId}/links/${sourceLinkId}/copy`,
|
|
452
452
|
{
|
|
453
453
|
TargetVolumeID: targetVolumeId,
|
|
454
|
-
TargetParentLinkID:
|
|
454
|
+
TargetParentLinkID: targetNodeId,
|
|
455
455
|
Hash: payload.nameHash,
|
|
456
456
|
Name: payload.encryptedName,
|
|
457
457
|
NameSignatureEmail: payload.nameSignatureEmail,
|