@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.
Files changed (65) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/internal/apiService/apiService.d.ts +1 -1
  3. package/dist/internal/apiService/apiService.js +22 -7
  4. package/dist/internal/apiService/apiService.js.map +1 -1
  5. package/dist/internal/apiService/apiService.test.js +13 -0
  6. package/dist/internal/apiService/apiService.test.js.map +1 -1
  7. package/dist/internal/errors.js +35 -2
  8. package/dist/internal/errors.js.map +1 -1
  9. package/dist/internal/events/apiService.d.ts +4 -2
  10. package/dist/internal/events/apiService.js +17 -13
  11. package/dist/internal/events/apiService.js.map +1 -1
  12. package/dist/internal/events/index.d.ts +12 -1
  13. package/dist/internal/events/index.js +17 -1
  14. package/dist/internal/events/index.js.map +1 -1
  15. package/dist/internal/events/index.test.d.ts +1 -0
  16. package/dist/internal/events/index.test.js +58 -0
  17. package/dist/internal/events/index.test.js.map +1 -0
  18. package/dist/internal/nodes/cryptoService.d.ts +1 -0
  19. package/dist/internal/nodes/cryptoService.js +4 -0
  20. package/dist/internal/nodes/cryptoService.js.map +1 -1
  21. package/dist/internal/nodes/nodesRevisions.d.ts +1 -1
  22. package/dist/internal/nodes/nodesRevisions.js +3 -0
  23. package/dist/internal/nodes/nodesRevisions.js.map +1 -1
  24. package/dist/internal/photos/addToAlbum.js +1 -1
  25. package/dist/internal/photos/addToAlbum.js.map +1 -1
  26. package/dist/internal/photos/addToAlbum.test.js +12 -12
  27. package/dist/internal/photos/addToAlbum.test.js.map +1 -1
  28. package/dist/internal/photos/apiService.d.ts +3 -3
  29. package/dist/internal/photos/apiService.js +5 -5
  30. package/dist/internal/photos/apiService.js.map +1 -1
  31. package/dist/internal/photos/apiService.test.js +4 -4
  32. package/dist/internal/photos/apiService.test.js.map +1 -1
  33. package/dist/internal/photos/photosManager.d.ts +1 -0
  34. package/dist/internal/photos/photosManager.js +38 -2
  35. package/dist/internal/photos/photosManager.js.map +1 -1
  36. package/dist/internal/photos/photosManager.test.js +26 -0
  37. package/dist/internal/photos/photosManager.test.js.map +1 -1
  38. package/dist/internal/sharingPublic/nodes.d.ts +1 -0
  39. package/dist/internal/sharingPublic/nodes.js +2 -0
  40. package/dist/internal/sharingPublic/nodes.js.map +1 -1
  41. package/dist/protonDriveClient.d.ts +14 -2
  42. package/dist/protonDriveClient.js +6 -0
  43. package/dist/protonDriveClient.js.map +1 -1
  44. package/dist/protonDrivePhotosClient.d.ts +10 -2
  45. package/dist/protonDrivePhotosClient.js +6 -0
  46. package/dist/protonDrivePhotosClient.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/index.ts +1 -1
  49. package/src/internal/apiService/apiService.test.ts +16 -0
  50. package/src/internal/apiService/apiService.ts +20 -2
  51. package/src/internal/errors.ts +40 -1
  52. package/src/internal/events/apiService.ts +20 -17
  53. package/src/internal/events/index.test.ts +67 -0
  54. package/src/internal/events/index.ts +20 -2
  55. package/src/internal/nodes/cryptoService.ts +6 -0
  56. package/src/internal/nodes/nodesRevisions.ts +5 -1
  57. package/src/internal/photos/addToAlbum.test.ts +12 -12
  58. package/src/internal/photos/addToAlbum.ts +1 -1
  59. package/src/internal/photos/apiService.test.ts +4 -4
  60. package/src/internal/photos/apiService.ts +6 -6
  61. package/src/internal/photos/photosManager.test.ts +36 -1
  62. package/src/internal/photos/photosManager.ts +48 -7
  63. package/src/internal/sharingPublic/nodes.ts +3 -0
  64. package/src/protonDriveClient.ts +18 -1
  65. package/src/protonDrivePhotosClient.ts +15 -5
@@ -72,7 +72,7 @@ export function isNetworkError(error: unknown): boolean {
72
72
  if (!(error instanceof Error)) {
73
73
  return false;
74
74
  }
75
- return (
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 GetCoreEventResponse =
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<GetCoreEventResponse>(`core/v5/events/${eventId}`);
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
- // FIXME: Allow to pass own core events manager from the public interface.
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
- copyPhotoToAlbum: jest.fn(),
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.copyPhotoToAlbum.mockImplementation(async (albumUid, payload) => {
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.copyPhotoToAlbum.mockImplementation(async (albumUid, payload) => {
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.copyPhotoToAlbum).toHaveBeenCalledTimes(1);
355
- const params = apiService.copyPhotoToAlbum.mock.calls[0];
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.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts
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.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts
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.copyPhotoToAlbum.mockRejectedValue(new Error('API error'));
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.copyPhotoToAlbum).toHaveBeenCalledTimes(2);
471
- expect(apiService.copyPhotoToAlbum.mock.calls[0][1].nodeUid).toBe(differentVolumeUids[0]);
472
- expect(apiService.copyPhotoToAlbum.mock.calls[1][1].nodeUid).toBe(differentVolumeUids[1]);
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.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts
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.copyPhotoToAlbum(
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('copyPhotoToAlbum', () => {
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.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]);
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.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]);
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.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]);
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 copyPhotoToAlbum} method.
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 to a shared album on a different volume.
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 copyPhotoToAlbum(
442
- albumNodeUid: string,
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: targetAlbumLinkId } = splitNodeUid(albumNodeUid);
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: targetAlbumLinkId,
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 { PhotoAlreadyInTargetError, PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder';
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: string;
47
- additionalRelatedPhotoNodeUids: string[];
48
- }[] = nodeUids.map((nodeUid) => ({ photoNodeUid: nodeUid, additionalRelatedPhotoNodeUids: [] }));
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
- for (const batch of createBatches(payloads)) {
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,