@protontech/drive-sdk 0.15.1 → 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/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/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/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/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/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/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/nodesRevisions.ts +5 -1
- package/src/internal/photos/addToAlbum.test.ts +12 -12
- package/src/internal/photos/addToAlbum.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/sharingPublic/nodes.ts +3 -0
- package/src/protonDriveClient.ts +18 -1
- package/src/protonDrivePhotosClient.ts +15 -5
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
|
|
|
@@ -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> {
|
|
@@ -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,
|
|
@@ -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,
|
|
@@ -58,7 +58,7 @@ async function collectSaveToTimelineResults(manager: PhotosManager, nodeUids: st
|
|
|
58
58
|
describe('PhotosManager', () => {
|
|
59
59
|
let logger: ReturnType<typeof getMockLogger>;
|
|
60
60
|
let apiService: jest.Mocked<
|
|
61
|
-
Pick<PhotosAPIService, 'addPhotoTags' | 'removePhotoTags' | 'setPhotoFavorite' | 'transferPhotos'>
|
|
61
|
+
Pick<PhotosAPIService, 'addPhotoTags' | 'removePhotoTags' | 'setPhotoFavorite' | 'transferPhotos' | 'copyPhoto'>
|
|
62
62
|
>;
|
|
63
63
|
let cryptoService: jest.Mocked<Pick<AlbumsCryptoService, 'encryptPhotoForAlbum'>>;
|
|
64
64
|
let nodesService: jest.Mocked<
|
|
@@ -70,6 +70,7 @@ describe('PhotosManager', () => {
|
|
|
70
70
|
| 'iterateNodes'
|
|
71
71
|
| 'getNodePrivateAndSessionKeys'
|
|
72
72
|
| 'notifyNodeChanged'
|
|
73
|
+
| 'notifyChildCreated'
|
|
73
74
|
>
|
|
74
75
|
>;
|
|
75
76
|
let manager: PhotosManager;
|
|
@@ -92,6 +93,7 @@ describe('PhotosManager', () => {
|
|
|
92
93
|
removePhotoTags: jest.fn().mockResolvedValue(undefined),
|
|
93
94
|
setPhotoFavorite: jest.fn().mockResolvedValue(undefined),
|
|
94
95
|
transferPhotos: jest.fn().mockImplementation(async function* () {}),
|
|
96
|
+
copyPhoto: jest.fn().mockResolvedValue('volume1~newPhoto'),
|
|
95
97
|
};
|
|
96
98
|
|
|
97
99
|
cryptoService = {
|
|
@@ -122,6 +124,7 @@ describe('PhotosManager', () => {
|
|
|
122
124
|
passphraseSessionKey: 'passphraseSessionKey' as any,
|
|
123
125
|
}),
|
|
124
126
|
notifyNodeChanged: jest.fn().mockResolvedValue(undefined),
|
|
127
|
+
notifyChildCreated: jest.fn().mockResolvedValue(undefined),
|
|
125
128
|
};
|
|
126
129
|
|
|
127
130
|
manager = new PhotosManager(logger, apiService as any, cryptoService as any, nodesService as any);
|
|
@@ -304,5 +307,37 @@ describe('PhotosManager', () => {
|
|
|
304
307
|
);
|
|
305
308
|
expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1');
|
|
306
309
|
});
|
|
310
|
+
|
|
311
|
+
it('copies cross-volume photo and notifies parent root folder', async () => {
|
|
312
|
+
apiService.copyPhoto.mockResolvedValue('volume1~newPhoto1');
|
|
313
|
+
|
|
314
|
+
const results = await collectSaveToTimelineResults(manager, ['volume2~photo1']);
|
|
315
|
+
|
|
316
|
+
expect(results).toEqual([{ uid: 'volume2~photo1', ok: true }]);
|
|
317
|
+
expect(apiService.copyPhoto).toHaveBeenCalledTimes(1);
|
|
318
|
+
expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('volume1~root');
|
|
319
|
+
expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('re-queues cross-volume photo once on MissingRelatedPhotosError then succeeds', async () => {
|
|
323
|
+
const missingRelatedUid = 'volume2~related1';
|
|
324
|
+
let copyCall = 0;
|
|
325
|
+
apiService.copyPhoto.mockImplementation(async () => {
|
|
326
|
+
copyCall++;
|
|
327
|
+
if (copyCall === 1) {
|
|
328
|
+
throw new MissingRelatedPhotosError([missingRelatedUid]);
|
|
329
|
+
}
|
|
330
|
+
return 'volume1~newPhoto1';
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const results = await collectSaveToTimelineResults(manager, ['volume2~photo1']);
|
|
334
|
+
|
|
335
|
+
expect(results).toEqual([{ uid: 'volume2~photo1', ok: true }]);
|
|
336
|
+
expect(apiService.copyPhoto).toHaveBeenCalledTimes(2);
|
|
337
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
338
|
+
`Missing related photos for saving volume2~photo1, re-queuing: ${missingRelatedUid}`,
|
|
339
|
+
);
|
|
340
|
+
expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('volume1~root');
|
|
341
|
+
});
|
|
307
342
|
});
|
|
308
343
|
});
|
|
@@ -3,12 +3,17 @@ import { c } from 'ttag';
|
|
|
3
3
|
import { AbortError } from '../../errors';
|
|
4
4
|
import { Logger, NodeResultWithError, PhotoTag } from '../../interface';
|
|
5
5
|
import { batch } from '../batch';
|
|
6
|
+
import { splitNodeUid } from '../uids';
|
|
6
7
|
import { createBatches } from './addToAlbum';
|
|
7
8
|
import { AlbumsCryptoService } from './albumsCrypto';
|
|
8
9
|
import { PhotosAPIService } from './apiService';
|
|
9
10
|
import { MissingRelatedPhotosError } from './errors';
|
|
10
11
|
import { PhotosNodesAccess } from './nodes';
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
PhotoAlreadyInTargetError,
|
|
14
|
+
PhotoTransferPayloadBuilder,
|
|
15
|
+
TransferEncryptedPhotoPayload,
|
|
16
|
+
} from './photosTransferPayloadBuilder';
|
|
12
17
|
|
|
13
18
|
/**
|
|
14
19
|
* The number of photos that are loaded in parallel to prepare the payloads.
|
|
@@ -39,13 +44,14 @@ export class PhotosManager {
|
|
|
39
44
|
|
|
40
45
|
async *saveToTimeline(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResultWithError> {
|
|
41
46
|
const rootNode = await this.nodesService.getVolumeRootFolder();
|
|
47
|
+
const { volumeId: userVolumeId } = splitNodeUid(rootNode.uid);
|
|
42
48
|
const volumeRootKeys = await this.nodesService.getNodeKeys(rootNode.uid);
|
|
43
49
|
const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid: rootNode.uid });
|
|
44
50
|
|
|
45
|
-
const queue: {
|
|
46
|
-
photoNodeUid:
|
|
47
|
-
additionalRelatedPhotoNodeUids:
|
|
48
|
-
}
|
|
51
|
+
const queue: { photoNodeUid: string; additionalRelatedPhotoNodeUids: string[] }[] = nodeUids.map((nodeUid) => ({
|
|
52
|
+
photoNodeUid: nodeUid,
|
|
53
|
+
additionalRelatedPhotoNodeUids: [],
|
|
54
|
+
}));
|
|
49
55
|
const retriedPhotoUids = new Set<string>();
|
|
50
56
|
|
|
51
57
|
while (queue.length > 0) {
|
|
@@ -62,7 +68,10 @@ export class PhotosManager {
|
|
|
62
68
|
yield { uid, ok: false, error };
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
|
|
71
|
+
const sameVolumePayloads = payloads.filter((p) => splitNodeUid(p.nodeUid).volumeId === userVolumeId);
|
|
72
|
+
const crossVolumePayloads = payloads.filter((p) => splitNodeUid(p.nodeUid).volumeId !== userVolumeId);
|
|
73
|
+
|
|
74
|
+
for (const batch of createBatches(sameVolumePayloads)) {
|
|
66
75
|
for await (const result of this.apiService.transferPhotos(rootNode.uid, batch, signal)) {
|
|
67
76
|
if (
|
|
68
77
|
!result.ok &&
|
|
@@ -79,16 +88,48 @@ export class PhotosManager {
|
|
|
79
88
|
});
|
|
80
89
|
continue;
|
|
81
90
|
}
|
|
82
|
-
|
|
83
91
|
if (result.ok) {
|
|
84
92
|
await this.nodesService.notifyNodeChanged(result.uid);
|
|
85
93
|
}
|
|
86
94
|
yield result;
|
|
87
95
|
}
|
|
88
96
|
}
|
|
97
|
+
|
|
98
|
+
// Cross-volume photos (e.g. from shared-with-me albums): copy into the user's own
|
|
99
|
+
// timeline root using the generic copy endpoint.
|
|
100
|
+
for (const payload of crossVolumePayloads) {
|
|
101
|
+
try {
|
|
102
|
+
await this.copyPhoto(payload, signal);
|
|
103
|
+
await this.nodesService.notifyChildCreated(rootNode.uid);
|
|
104
|
+
yield { uid: payload.nodeUid, ok: true };
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error instanceof MissingRelatedPhotosError && !retriedPhotoUids.has(payload.nodeUid)) {
|
|
107
|
+
retriedPhotoUids.add(payload.nodeUid);
|
|
108
|
+
this.logger.info(
|
|
109
|
+
`Missing related photos for saving ${payload.nodeUid}, re-queuing: ${error.missingNodeUids.join(', ')}`,
|
|
110
|
+
);
|
|
111
|
+
queue.push({
|
|
112
|
+
photoNodeUid: payload.nodeUid,
|
|
113
|
+
additionalRelatedPhotoNodeUids: error.missingNodeUids,
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
yield {
|
|
118
|
+
uid: payload.nodeUid,
|
|
119
|
+
ok: false,
|
|
120
|
+
error:
|
|
121
|
+
error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
89
125
|
}
|
|
90
126
|
}
|
|
91
127
|
|
|
128
|
+
private async copyPhoto(payload: TransferEncryptedPhotoPayload, signal?: AbortSignal): Promise<string> {
|
|
129
|
+
const rootNode = await this.nodesService.getVolumeRootFolder();
|
|
130
|
+
return this.apiService.copyPhoto(rootNode.uid, payload, signal);
|
|
131
|
+
}
|
|
132
|
+
|
|
92
133
|
async *updatePhotos(photos: UpdatePhotoSettings[], signal?: AbortSignal): AsyncGenerator<NodeResultWithError> {
|
|
93
134
|
for await (const {
|
|
94
135
|
photoSettings: { nodeUid, tagsToAdd, tagsToRemove },
|
|
@@ -17,6 +17,9 @@ import { makeNodeUid, splitNodeUid } from '../uids';
|
|
|
17
17
|
import { SharingPublicSharesManager } from './shares';
|
|
18
18
|
|
|
19
19
|
export class SharingPublicNodesCryptoService extends NodesCryptoService {
|
|
20
|
+
// Do not allow fallback verification for public links, because it is not possible to load owners' address keys.
|
|
21
|
+
protected allowContentKeyPacketFallbackVerification = false;
|
|
22
|
+
|
|
20
23
|
async generateDocument(
|
|
21
24
|
parentKeys: { key: PrivateKey; hashKey: Uint8Array<ArrayBuffer> },
|
|
22
25
|
signingKeys: NodeSigningKeys,
|