@protontech/drive-sdk 0.7.0 → 0.7.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 (99) hide show
  1. package/dist/interface/index.d.ts +1 -0
  2. package/dist/interface/index.js.map +1 -1
  3. package/dist/interface/nodes.d.ts +14 -10
  4. package/dist/interface/nodes.js +5 -8
  5. package/dist/interface/nodes.js.map +1 -1
  6. package/dist/interface/photos.d.ts +62 -0
  7. package/dist/interface/photos.js +3 -0
  8. package/dist/interface/photos.js.map +1 -0
  9. package/dist/internal/apiService/driveTypes.d.ts +1294 -517
  10. package/dist/internal/errors.d.ts +1 -0
  11. package/dist/internal/errors.js +4 -0
  12. package/dist/internal/errors.js.map +1 -1
  13. package/dist/internal/nodes/apiService.d.ts +60 -9
  14. package/dist/internal/nodes/apiService.js +125 -81
  15. package/dist/internal/nodes/apiService.js.map +1 -1
  16. package/dist/internal/nodes/apiService.test.js +2 -0
  17. package/dist/internal/nodes/apiService.test.js.map +1 -1
  18. package/dist/internal/nodes/cache.d.ts +16 -8
  19. package/dist/internal/nodes/cache.js +19 -5
  20. package/dist/internal/nodes/cache.js.map +1 -1
  21. package/dist/internal/nodes/cache.test.js +1 -0
  22. package/dist/internal/nodes/cache.test.js.map +1 -1
  23. package/dist/internal/nodes/cryptoService.d.ts +1 -1
  24. package/dist/internal/nodes/cryptoService.js +4 -4
  25. package/dist/internal/nodes/cryptoService.js.map +1 -1
  26. package/dist/internal/nodes/cryptoService.test.js +3 -3
  27. package/dist/internal/nodes/cryptoService.test.js.map +1 -1
  28. package/dist/internal/nodes/events.d.ts +2 -2
  29. package/dist/internal/nodes/events.js.map +1 -1
  30. package/dist/internal/nodes/index.test.js +1 -0
  31. package/dist/internal/nodes/index.test.js.map +1 -1
  32. package/dist/internal/nodes/interface.d.ts +1 -0
  33. package/dist/internal/nodes/nodesAccess.d.ts +29 -20
  34. package/dist/internal/nodes/nodesAccess.js +41 -29
  35. package/dist/internal/nodes/nodesAccess.js.map +1 -1
  36. package/dist/internal/nodes/nodesManagement.d.ts +32 -12
  37. package/dist/internal/nodes/nodesManagement.js +30 -13
  38. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  39. package/dist/internal/nodes/nodesManagement.test.js +39 -4
  40. package/dist/internal/nodes/nodesManagement.test.js.map +1 -1
  41. package/dist/internal/nodes/nodesRevisions.d.ts +2 -2
  42. package/dist/internal/nodes/nodesRevisions.js.map +1 -1
  43. package/dist/internal/photos/albums.d.ts +2 -2
  44. package/dist/internal/photos/albums.js.map +1 -1
  45. package/dist/internal/photos/index.d.ts +19 -3
  46. package/dist/internal/photos/index.js +38 -8
  47. package/dist/internal/photos/index.js.map +1 -1
  48. package/dist/internal/photos/interface.d.ts +18 -9
  49. package/dist/internal/photos/nodes.d.ts +57 -0
  50. package/dist/internal/photos/nodes.js +166 -0
  51. package/dist/internal/photos/nodes.js.map +1 -0
  52. package/dist/internal/photos/nodes.test.d.ts +1 -0
  53. package/dist/internal/photos/nodes.test.js +233 -0
  54. package/dist/internal/photos/nodes.test.js.map +1 -0
  55. package/dist/internal/photos/timeline.d.ts +2 -2
  56. package/dist/internal/photos/timeline.js.map +1 -1
  57. package/dist/internal/photos/timeline.test.js.map +1 -1
  58. package/dist/protonDriveClient.d.ts +10 -1
  59. package/dist/protonDriveClient.js +18 -3
  60. package/dist/protonDriveClient.js.map +1 -1
  61. package/dist/protonDrivePhotosClient.d.ts +12 -8
  62. package/dist/protonDrivePhotosClient.js +15 -9
  63. package/dist/protonDrivePhotosClient.js.map +1 -1
  64. package/dist/protonDrivePublicLinkClient.d.ts +7 -1
  65. package/dist/protonDrivePublicLinkClient.js +9 -0
  66. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  67. package/dist/transformers.d.ts +7 -2
  68. package/dist/transformers.js +37 -0
  69. package/dist/transformers.js.map +1 -1
  70. package/package.json +1 -1
  71. package/src/interface/index.ts +1 -0
  72. package/src/interface/nodes.ts +14 -11
  73. package/src/interface/photos.ts +67 -0
  74. package/src/internal/apiService/driveTypes.ts +1294 -517
  75. package/src/internal/errors.ts +4 -0
  76. package/src/internal/nodes/apiService.test.ts +2 -0
  77. package/src/internal/nodes/apiService.ts +187 -114
  78. package/src/internal/nodes/cache.test.ts +1 -0
  79. package/src/internal/nodes/cache.ts +32 -13
  80. package/src/internal/nodes/cryptoService.test.ts +13 -3
  81. package/src/internal/nodes/cryptoService.ts +4 -4
  82. package/src/internal/nodes/events.ts +2 -2
  83. package/src/internal/nodes/index.test.ts +1 -0
  84. package/src/internal/nodes/interface.ts +1 -0
  85. package/src/internal/nodes/nodesAccess.ts +82 -54
  86. package/src/internal/nodes/nodesManagement.test.ts +48 -4
  87. package/src/internal/nodes/nodesManagement.ts +76 -26
  88. package/src/internal/nodes/nodesRevisions.ts +3 -3
  89. package/src/internal/photos/albums.ts +2 -2
  90. package/src/internal/photos/index.ts +45 -3
  91. package/src/internal/photos/interface.ts +21 -9
  92. package/src/internal/photos/nodes.test.ts +258 -0
  93. package/src/internal/photos/nodes.ts +234 -0
  94. package/src/internal/photos/timeline.test.ts +2 -2
  95. package/src/internal/photos/timeline.ts +2 -2
  96. package/src/protonDriveClient.ts +20 -3
  97. package/src/protonDrivePhotosClient.ts +31 -23
  98. package/src/protonDrivePublicLinkClient.ts +11 -0
  99. package/src/transformers.ts +49 -2
@@ -6,6 +6,10 @@ import {
6
6
  ProtonDriveEntitiesCache,
7
7
  ProtonDriveTelemetry,
8
8
  } from '../../interface';
9
+ import { NodesCryptoService } from '../nodes/cryptoService';
10
+ import { NodesCryptoReporter } from '../nodes/cryptoReporter';
11
+ import { NodesCryptoCache } from '../nodes/cryptoCache';
12
+ import { ShareTargetType } from '../shares';
9
13
  import { SharesCache } from '../shares/cache';
10
14
  import { SharesCryptoCache } from '../shares/cryptoCache';
11
15
  import { SharesCryptoService } from '../shares/cryptoService';
@@ -14,7 +18,8 @@ import { UploadTelemetry } from '../upload/telemetry';
14
18
  import { UploadQueue } from '../upload/queue';
15
19
  import { Albums } from './albums';
16
20
  import { PhotosAPIService } from './apiService';
17
- import { NodesService, SharesService } from './interface';
21
+ import { SharesService } from './interface';
22
+ import { PhotosNodesAPIService, PhotosNodesAccess, PhotosNodesCache, PhotosNodesManagement } from './nodes';
18
23
  import { PhotoSharesManager } from './shares';
19
24
  import { PhotosTimeline } from './timeline';
20
25
  import {
@@ -24,7 +29,10 @@ import {
24
29
  PhotoUploadManager,
25
30
  PhotoUploadMetadata,
26
31
  } from './upload';
27
- import { ShareTargetType } from '../shares';
32
+ import { NodesRevisons } from '../nodes/nodesRevisions';
33
+ import { NodesEventsHandler } from '../nodes/events';
34
+
35
+ export type { DecryptedPhotoNode } from './interface';
28
36
 
29
37
  // Only photos and albums can be shared in photos volume.
30
38
  export const PHOTOS_SHARE_TARGET_TYPES = [ShareTargetType.Photo, ShareTargetType.Album];
@@ -40,7 +48,7 @@ export function initPhotosModule(
40
48
  apiService: DriveAPIService,
41
49
  driveCrypto: DriveCrypto,
42
50
  photoShares: PhotoSharesManager,
43
- nodesService: NodesService,
51
+ nodesService: PhotosNodesAccess,
44
52
  ) {
45
53
  const api = new PhotosAPIService(apiService);
46
54
  const timeline = new PhotosTimeline(
@@ -89,6 +97,40 @@ export function initPhotoSharesModule(
89
97
  );
90
98
  }
91
99
 
100
+ /**
101
+ * Provides facade for the photo nodes module.
102
+ *
103
+ * The photo nodes module wraps the core nodes module and adds photo specific
104
+ * metadata. It provides the same interface so it can be used in the same way.
105
+ */
106
+ export function initPhotosNodesModule(
107
+ telemetry: ProtonDriveTelemetry,
108
+ apiService: DriveAPIService,
109
+ driveEntitiesCache: ProtonDriveEntitiesCache,
110
+ driveCryptoCache: ProtonDriveCryptoCache,
111
+ account: ProtonDriveAccount,
112
+ driveCrypto: DriveCrypto,
113
+ sharesService: PhotoSharesManager,
114
+ clientUid: string | undefined,
115
+ ) {
116
+ const api = new PhotosNodesAPIService(telemetry.getLogger('nodes-api'), apiService, clientUid);
117
+ const cache = new PhotosNodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache);
118
+ const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache);
119
+ const cryptoReporter = new NodesCryptoReporter(telemetry, sharesService);
120
+ const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter);
121
+ const nodesAccess = new PhotosNodesAccess(telemetry, api, cache, cryptoCache, cryptoService, sharesService);
122
+ const nodesEventHandler = new NodesEventsHandler(telemetry.getLogger('nodes-events'), cache);
123
+ const nodesManagement = new PhotosNodesManagement(api, cryptoCache, cryptoService, nodesAccess);
124
+ const nodesRevisions = new NodesRevisons(telemetry.getLogger('nodes'), api, cryptoService, nodesAccess);
125
+
126
+ return {
127
+ access: nodesAccess,
128
+ management: nodesManagement,
129
+ revisions: nodesRevisions,
130
+ eventHandler: nodesEventHandler,
131
+ };
132
+ }
133
+
92
134
  /**
93
135
  * Provides facade for the photo upload module.
94
136
  *
@@ -1,6 +1,6 @@
1
1
  import { PrivateKey } from '../../crypto';
2
- import { MissingNode, MetricVolumeType } from '../../interface';
3
- import { DecryptedNode } from '../nodes';
2
+ import { MetricVolumeType, PhotoAttributes } from '../../interface';
3
+ import { DecryptedNode, EncryptedNode, DecryptedUnparsedNode } from '../nodes/interface';
4
4
  import { EncryptedShare } from '../shares';
5
5
 
6
6
  export interface SharesService {
@@ -23,10 +23,22 @@ export interface SharesService {
23
23
  getVolumeMetricContext(volumeId: string): Promise<MetricVolumeType>;
24
24
  }
25
25
 
26
- export interface NodesService {
27
- getNode(nodeUid: string): Promise<DecryptedNode>;
28
- iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<DecryptedNode | MissingNode>;
29
- getNodeKeys(nodeUid: string): Promise<{
30
- hashKey?: Uint8Array;
31
- }>;
32
- }
26
+ export type EncryptedPhotoNode = EncryptedNode & {
27
+ photo?: EcnryptedPhotoAttributes;
28
+ };
29
+
30
+ export type DecryptedUnparsedPhotoNode = DecryptedUnparsedNode & {
31
+ photo?: PhotoAttributes;
32
+ };
33
+
34
+ export type DecryptedPhotoNode = DecryptedNode & {
35
+ photo?: PhotoAttributes;
36
+ };
37
+
38
+ export type EcnryptedPhotoAttributes = Omit<PhotoAttributes, 'albums'> & {
39
+ contentHash?: string;
40
+ albums: (PhotoAttributes['albums'][0] & {
41
+ nameHash?: string;
42
+ contentHash?: string;
43
+ })[];
44
+ };
@@ -0,0 +1,258 @@
1
+ import { MemoryCache } from '../../cache';
2
+ import { NodeType, MemberRole } from '../../interface';
3
+ import { getMockLogger } from '../../tests/logger';
4
+ import { getMockTelemetry } from '../../tests/telemetry';
5
+ import { DriveAPIService } from '../apiService';
6
+ import { PhotosNodesAPIService, PhotosNodesCache, PhotosNodesAccess, PhotosNodesCryptoService } from './nodes';
7
+
8
+ function generateAPINode() {
9
+ return {
10
+ Link: {
11
+ LinkID: 'linkId',
12
+ ParentLinkID: 'parentLinkId',
13
+ NameHash: 'nameHash',
14
+ CreateTime: 123456789,
15
+ ModifyTime: 1234567890,
16
+ TrashTime: 0,
17
+ Name: 'encName',
18
+ SignatureEmail: 'sigEmail',
19
+ NameSignatureEmail: 'nameSigEmail',
20
+ NodeKey: 'nodeKey',
21
+ NodePassphrase: 'nodePass',
22
+ NodePassphraseSignature: 'nodePassSig',
23
+ },
24
+ SharingSummary: null,
25
+ Sharing: null,
26
+ Membership: null,
27
+ };
28
+ }
29
+
30
+ function generateAPIFolderNode(linkOverrides = {}, overrides = {}) {
31
+ const node = generateAPINode();
32
+ return {
33
+ ...node,
34
+ Link: { ...node.Link, Type: 1, ...linkOverrides },
35
+ Folder: { XAttr: '{folder}', NodeHashKey: 'nodeHashKey' },
36
+ Photo: null,
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ function generateAPIAlbumNode(linkOverrides = {}, overrides = {}) {
42
+ const node = generateAPINode();
43
+ return {
44
+ ...node,
45
+ Link: { ...node.Link, Type: 3, ...linkOverrides },
46
+ Photo: null,
47
+ Folder: null,
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ function generateAPIPhotoNode(linkOverrides = {}, overrides = {}) {
53
+ const node = generateAPINode();
54
+ return {
55
+ ...node,
56
+ Link: { ...node.Link, Type: 2, ...linkOverrides },
57
+ Photo: {
58
+ CaptureTime: 1700000000,
59
+ MainPhotoLinkID: null,
60
+ RelatedPhotosLinkIDs: [],
61
+ ContentHash: 'contentHash123',
62
+ Tags: [1, 2],
63
+ Albums: [
64
+ {
65
+ AlbumLinkID: 'albumLinkId1',
66
+ AddedTime: 1700001000,
67
+ Hash: 'albumHash',
68
+ ContentHash: 'albumContentHash',
69
+ },
70
+ ],
71
+ ActiveRevision: {
72
+ RevisionID: 'revisionId',
73
+ CreateTime: 1234567890,
74
+ SignatureEmail: 'revSigEmail',
75
+ XAttr: '{photo}',
76
+ EncryptedSize: 12,
77
+ },
78
+ MediaType: 'image/jpeg',
79
+ ContentKeyPacket: 'contentKeyPacket',
80
+ ContentKeyPacketSignature: 'contentKeyPacketSig',
81
+ },
82
+ Folder: null,
83
+ ...overrides,
84
+ };
85
+ }
86
+
87
+ describe('PhotosNodesAPIService', () => {
88
+ let apiMock: DriveAPIService;
89
+ let api: PhotosNodesAPIService;
90
+
91
+ beforeEach(() => {
92
+ // @ts-expect-error Mocking for testing purposes
93
+ apiMock = {
94
+ post: jest.fn(),
95
+ };
96
+ api = new PhotosNodesAPIService(getMockLogger(), apiMock, 'clientUid');
97
+ });
98
+
99
+ describe('linkToEncryptedNode', () => {
100
+ async function testIterateNodes(mockedLink: object, expectedType: NodeType) {
101
+ apiMock.post = jest.fn().mockResolvedValue({ Links: [mockedLink] });
102
+
103
+ const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], 'volumeId'));
104
+ expect(nodes).toHaveLength(1);
105
+ expect(nodes[0].type).toBe(expectedType);
106
+ }
107
+
108
+ it('should convert folder (type 1) to folder node', async () => {
109
+ await testIterateNodes(generateAPIFolderNode(), NodeType.Folder);
110
+ });
111
+
112
+ it('should convert album (type 3) to album node', async () => {
113
+ await testIterateNodes(generateAPIAlbumNode(), NodeType.Album);
114
+ });
115
+
116
+ it('should convert photo (type 2) to photo node with photo attributes', async () => {
117
+ apiMock.post = jest.fn().mockResolvedValue({ Links: [generateAPIPhotoNode()] });
118
+
119
+ const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], 'volumeId'));
120
+
121
+ expect(nodes).toHaveLength(1);
122
+ expect(nodes[0].type).toBe(NodeType.Photo);
123
+ expect(nodes[0].photo).toBeDefined();
124
+ expect(nodes[0].photo?.captureTime).toEqual(new Date(1700000000 * 1000));
125
+ expect(nodes[0].photo?.tags).toEqual([1, 2]);
126
+ expect(nodes[0].photo?.albums).toHaveLength(1);
127
+ expect(nodes[0].photo?.albums[0].nodeUid).toBe('volumeId~albumLinkId1');
128
+ expect(nodes[0].photo?.albums[0].additionTime).toEqual(new Date(1700001000 * 1000));
129
+ });
130
+ });
131
+ });
132
+
133
+ describe('PhotosNodesCache', () => {
134
+ let cache: PhotosNodesCache;
135
+
136
+ beforeEach(() => {
137
+ const memoryCache = new MemoryCache<string>();
138
+ cache = new PhotosNodesCache(getMockLogger(), memoryCache);
139
+ });
140
+
141
+ describe('deserialiseNode', () => {
142
+ it('should convert photo attributes dates from strings to Date objects', () => {
143
+ const serialisedNode = JSON.stringify({
144
+ uid: 'volumeId~linkId',
145
+ parentUid: 'volumeId~parentLinkId',
146
+ type: NodeType.Photo,
147
+ directRole: MemberRole.Admin,
148
+ isShared: false,
149
+ isSharedPublicly: false,
150
+ creationTime: '2023-11-14T22:13:20.000Z',
151
+ modificationTime: '2023-11-14T22:13:20.000Z',
152
+ photo: {
153
+ captureTime: '2023-11-14T22:13:20.000Z',
154
+ mainPhotoNodeUid: undefined,
155
+ relatedPhotoNodeUids: [],
156
+ tags: [1],
157
+ albums: [
158
+ {
159
+ nodeUid: 'volumeId~albumId',
160
+ additionTime: '2023-11-15T10:00:00.000Z',
161
+ },
162
+ ],
163
+ },
164
+ });
165
+
166
+ const node = cache.deserialiseNode(serialisedNode);
167
+
168
+ expect(node.photo).toBeDefined();
169
+ expect(node.photo?.captureTime).toBeInstanceOf(Date);
170
+ expect(node.photo?.captureTime).toEqual(new Date('2023-11-14T22:13:20.000Z'));
171
+ expect(node.photo?.albums[0].additionTime).toBeInstanceOf(Date);
172
+ expect(node.photo?.albums[0].additionTime).toEqual(new Date('2023-11-15T10:00:00.000Z'));
173
+ });
174
+
175
+ it('should handle node without photo attributes', () => {
176
+ const serialisedNode = JSON.stringify({
177
+ uid: 'volumeId~linkId',
178
+ parentUid: 'volumeId~parentLinkId',
179
+ type: NodeType.Folder,
180
+ directRole: MemberRole.Admin,
181
+ isShared: false,
182
+ isSharedPublicly: false,
183
+ creationTime: '2023-11-14T22:13:20.000Z',
184
+ modificationTime: '2023-11-14T22:13:20.000Z',
185
+ });
186
+
187
+ const node = cache.deserialiseNode(serialisedNode);
188
+
189
+ expect(node.photo).toBeUndefined();
190
+ });
191
+ });
192
+ });
193
+
194
+ describe('PhotosNodesAccess', () => {
195
+ describe('parseNode', () => {
196
+ it('should keep photo type and add photo object', async () => {
197
+ const telemetry = getMockTelemetry();
198
+
199
+ // @ts-expect-error Mocking for testing purposes
200
+ const cryptoService: PhotosNodesCryptoService = {};
201
+ // @ts-expect-error Mocking for testing purposes
202
+ const apiService: PhotosNodesAPIService = {};
203
+ // @ts-expect-error Mocking for testing purposes
204
+ const cacheService: PhotosNodesCache = {};
205
+ // @ts-expect-error Mocking for testing purposes
206
+ const cryptoCache: NodesCryptoCache = {};
207
+ // @ts-expect-error Mocking for testing purposes
208
+ const sharesService: SharesService = {};
209
+
210
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
211
+ const nodesAccess = new PhotosNodesAccess(telemetry, apiService, cacheService, cryptoCache, cryptoService, sharesService);
212
+
213
+ const unparsedNode = {
214
+ uid: 'volumeId~linkId',
215
+ parentUid: 'volumeId~parentLinkId',
216
+ type: NodeType.Photo,
217
+ name: 'photo.jpg',
218
+ hash: 'hash123',
219
+ directRole: MemberRole.Admin,
220
+ isShared: false,
221
+ isSharedPublicly: false,
222
+ creationTime: new Date(),
223
+ modificationTime: new Date(),
224
+ trashTime: undefined,
225
+ mediaType: 'image/jpeg',
226
+ folder: undefined,
227
+ file: {
228
+ activeRevision: {
229
+ uid: 'revisionId',
230
+ state: 'active' as const,
231
+ creationTime: new Date(),
232
+ storageSize: 100,
233
+ signatureEmail: 'test@example.com',
234
+ claimedModificationTime: new Date(),
235
+ claimedSize: 100,
236
+ claimedDigests: { sha1: 'sha1hash' },
237
+ claimedBlockSizes: [100],
238
+ },
239
+ },
240
+ photo: {
241
+ captureTime: new Date('2023-11-14T22:13:20.000Z'),
242
+ mainPhotoNodeUid: undefined,
243
+ relatedPhotoNodeUids: [],
244
+ tags: [1, 2],
245
+ albums: [],
246
+ },
247
+ };
248
+
249
+ // @ts-expect-error Accessing protected method for testing
250
+ const parsedNode = nodesAccess.parseNode(unparsedNode);
251
+
252
+ expect(parsedNode.type).toBe(NodeType.Photo);
253
+ expect(parsedNode.photo).toBeDefined();
254
+ expect(parsedNode.photo?.captureTime).toEqual(new Date('2023-11-14T22:13:20.000Z'));
255
+ expect(parsedNode.photo?.tags).toEqual([1, 2]);
256
+ });
257
+ });
258
+ });
@@ -0,0 +1,234 @@
1
+ import { PrivateKey } from '../../crypto';
2
+ import { DecryptionError } from '../../errors';
3
+ import { NodeType } from '../../interface';
4
+ import { drivePaths } from '../apiService';
5
+ import { NodeAPIServiceBase, linkToEncryptedNode, linkToEncryptedNodeBaseMetadata } from '../nodes/apiService';
6
+ import { NodesCacheBase, serialiseNode, deserialiseNode } from '../nodes/cache';
7
+ import { NodesCryptoService } from '../nodes/cryptoService';
8
+ import { DecryptedNodeKeys } from '../nodes/interface';
9
+ import { NodesAccessBase, parseNode as parseNodeBase } from '../nodes/nodesAccess';
10
+ import { NodesManagementBase } from '../nodes/nodesManagement';
11
+ import { makeNodeUid } from '../uids';
12
+ import { EncryptedPhotoNode, DecryptedPhotoNode, DecryptedUnparsedPhotoNode } from './interface';
13
+
14
+ type PostLoadLinksMetadataRequest = Extract<
15
+ drivePaths['/drive/photos/volumes/{volumeID}/links']['post']['requestBody'],
16
+ { content: object }
17
+ >['content']['application/json'];
18
+ type PostLoadLinksMetadataResponse =
19
+ drivePaths['/drive/photos/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json'];
20
+
21
+ export class PhotosNodesAPIService extends NodeAPIServiceBase<
22
+ EncryptedPhotoNode,
23
+ PostLoadLinksMetadataResponse['Links'][0]
24
+ > {
25
+ protected async fetchNodeMetadata(volumeId: string, linkIds: string[], signal?: AbortSignal) {
26
+ const response = await this.apiService.post<PostLoadLinksMetadataRequest, PostLoadLinksMetadataResponse>(
27
+ `drive/photos/volumes/${volumeId}/links`,
28
+ {
29
+ LinkIDs: linkIds,
30
+ },
31
+ signal,
32
+ );
33
+ return response.Links;
34
+ }
35
+
36
+ protected linkToEncryptedNode(
37
+ volumeId: string,
38
+ link: PostLoadLinksMetadataResponse['Links'][0],
39
+ isOwnVolumeId: boolean,
40
+ ): EncryptedPhotoNode {
41
+ const { baseNodeMetadata, baseCryptoNodeMetadata } = linkToEncryptedNodeBaseMetadata(
42
+ this.logger,
43
+ volumeId,
44
+ link,
45
+ isOwnVolumeId,
46
+ );
47
+
48
+ if (link.Link.Type === 2 && link.Photo && link.Photo.ActiveRevision) {
49
+ const node = linkToEncryptedNode(
50
+ this.logger,
51
+ volumeId,
52
+ { ...link, File: link.Photo, Folder: null },
53
+ isOwnVolumeId,
54
+ );
55
+ return {
56
+ ...node,
57
+ type: NodeType.Photo,
58
+ photo: {
59
+ captureTime: new Date(link.Photo.CaptureTime * 1000),
60
+ mainPhotoNodeUid: link.Photo.MainPhotoLinkID
61
+ ? makeNodeUid(volumeId, link.Photo.MainPhotoLinkID)
62
+ : undefined,
63
+ relatedPhotoNodeUids: link.Photo.RelatedPhotosLinkIDs.map((relatedLinkId) =>
64
+ makeNodeUid(volumeId, relatedLinkId),
65
+ ),
66
+ contentHash: link.Photo.ContentHash || undefined,
67
+ tags: link.Photo.Tags,
68
+ albums: link.Photo.Albums.map((album) => ({
69
+ nodeUid: makeNodeUid(volumeId, album.AlbumLinkID),
70
+ additionTime: new Date(album.AddedTime * 1000),
71
+ nameHash: album.Hash,
72
+ contentHash: album.ContentHash,
73
+ })),
74
+ },
75
+ };
76
+ }
77
+
78
+ if (link.Link.Type === 3) {
79
+ return {
80
+ ...baseNodeMetadata,
81
+ encryptedCrypto: {
82
+ ...baseCryptoNodeMetadata,
83
+ },
84
+ };
85
+ }
86
+
87
+ const baseLink = {
88
+ Link: link.Link,
89
+ Membership: link.Membership,
90
+ Sharing: link.Sharing,
91
+ // @ts-expect-error The photo link can have a folder type, but not always. If not set, it will use other paths.
92
+ Folder: link.Folder,
93
+ File: null, // The photo link metadata never returns a file type.
94
+ };
95
+ return linkToEncryptedNode(this.logger, volumeId, baseLink, isOwnVolumeId);
96
+ }
97
+ }
98
+
99
+ export class PhotosNodesCache extends NodesCacheBase<DecryptedPhotoNode> {
100
+ serialiseNode(node: DecryptedPhotoNode): string {
101
+ return serialiseNode(node);
102
+ }
103
+
104
+ // TODO: use better deserialisation with validation
105
+ deserialiseNode(nodeData: string): DecryptedPhotoNode {
106
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
107
+ const node = deserialiseNode(nodeData) as any;
108
+
109
+ if (
110
+ !node ||
111
+ typeof node !== 'object' ||
112
+ (typeof node.photo !== 'object' && node.photo !== undefined) ||
113
+ (typeof node.photo?.captureTime !== 'string' && node.folder?.captureTime !== undefined) ||
114
+ (typeof node.photo?.albums !== 'object' && node.photo?.albums !== undefined)
115
+ ) {
116
+ throw new Error(`Invalid node data: ${nodeData}`);
117
+ }
118
+
119
+ return {
120
+ ...node,
121
+ photo: !node.photo
122
+ ? undefined
123
+ : {
124
+ captureTime: new Date(node.photo.captureTime),
125
+ mainPhotoNodeUid: node.photo.mainPhotoNodeUid,
126
+ relatedPhotoNodeUids: node.photo.relatedPhotoNodeUids,
127
+ contentHash: node.photo.contentHash,
128
+ tags: node.photo.tags,
129
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
130
+ albums: node.photo.albums?.map((album: any) => ({
131
+ nodeUid: album.nodeUid,
132
+ additionTime: new Date(album.additionTime),
133
+ })),
134
+ },
135
+ } as DecryptedPhotoNode;
136
+ }
137
+ }
138
+
139
+ export class PhotosNodesAccess extends NodesAccessBase<EncryptedPhotoNode, DecryptedPhotoNode, PhotosNodesCryptoService> {
140
+ async getParentKeys(
141
+ node: Pick<EncryptedPhotoNode, 'uid' | 'parentUid' | 'shareId' | 'photo'>,
142
+ ): Promise<Pick<DecryptedNodeKeys, 'key' | 'hashKey'>> {
143
+ if (node.parentUid || node.shareId) {
144
+ return super.getParentKeys(node);
145
+ }
146
+
147
+ if (node.photo?.albums.length) {
148
+ // If photo is in multiple albums, we just need to get keys for one of them.
149
+ // Prefer to find a cached key first.
150
+ for (const album of node.photo.albums) {
151
+ try {
152
+ const keys = await this.cryptoCache.getNodeKeys(album.nodeUid);
153
+ return {
154
+ key: keys.key,
155
+ hashKey: keys.hashKey,
156
+ };
157
+ } catch {
158
+ // We ignore missing or invalid keys here, its just optimization.
159
+ // If it cannot be fixed, it will bubble up later when requesting
160
+ // the node keys for one of the albums.
161
+ }
162
+ }
163
+
164
+ const albumNodeUid = node.photo.albums[0].nodeUid;
165
+ return this.getNodeKeys(albumNodeUid);
166
+ }
167
+
168
+ // This is bug that should not happen.
169
+ // API cannot provide node without parent or share or album.
170
+ throw new Error('Node has neither parent node nor share nor album');
171
+ }
172
+
173
+ protected getDegradedUndecryptableNode(
174
+ encryptedNode: EncryptedPhotoNode,
175
+ error: DecryptionError,
176
+ ): DecryptedPhotoNode {
177
+ return this.getDegradedUndecryptableNodeBase(encryptedNode, error);
178
+ }
179
+
180
+ protected parseNode(unparsedNode: DecryptedUnparsedPhotoNode): DecryptedPhotoNode {
181
+ if (unparsedNode.type === NodeType.Photo) {
182
+ const node = parseNodeBase(this.logger, {
183
+ ...unparsedNode,
184
+ type: NodeType.File,
185
+ });
186
+ return {
187
+ ...node,
188
+ photo: unparsedNode.photo,
189
+ type: NodeType.Photo,
190
+ };
191
+ }
192
+
193
+ return parseNodeBase(this.logger, unparsedNode);
194
+ }
195
+ }
196
+
197
+ export class PhotosNodesCryptoService extends NodesCryptoService {
198
+ async decryptNode(
199
+ encryptedNode: EncryptedPhotoNode,
200
+ parentKey: PrivateKey,
201
+ ): Promise<{ node: DecryptedUnparsedPhotoNode; keys?: DecryptedNodeKeys }> {
202
+ const decryptedNode = await super.decryptNode(encryptedNode, parentKey);
203
+
204
+ if (decryptedNode.node.type === NodeType.Photo) {
205
+ return {
206
+ node: {
207
+ ...decryptedNode.node,
208
+ photo: encryptedNode.photo,
209
+ },
210
+ };
211
+ }
212
+
213
+ return decryptedNode;
214
+ }
215
+ }
216
+
217
+ export class PhotosNodesManagement extends NodesManagementBase<
218
+ EncryptedPhotoNode,
219
+ DecryptedPhotoNode,
220
+ PhotosNodesCryptoService
221
+ > {
222
+ protected generateNodeFolder(
223
+ nodeUid: string,
224
+ parentNodeUid: string,
225
+ name: string,
226
+ encryptedCrypto: {
227
+ hash: string;
228
+ encryptedName: string;
229
+ signatureEmail: string | null;
230
+ },
231
+ ): DecryptedPhotoNode {
232
+ return this.generateNodeFolderBase(nodeUid, parentNodeUid, name, encryptedCrypto);
233
+ }
234
+ }
@@ -2,7 +2,7 @@ import { getMockLogger } from '../../tests/logger';
2
2
  import { DriveCrypto } from '../../crypto';
3
3
  import { makeNodeUid } from '../uids';
4
4
  import { PhotosAPIService } from './apiService';
5
- import { NodesService } from './interface';
5
+ import { PhotosNodesAccess } from './nodes';
6
6
  import { PhotoSharesManager } from './shares';
7
7
  import { PhotosTimeline } from './timeline';
8
8
 
@@ -11,7 +11,7 @@ describe('PhotosTimeline', () => {
11
11
  let apiService: PhotosAPIService;
12
12
  let driveCrypto: DriveCrypto;
13
13
  let photoShares: PhotoSharesManager;
14
- let nodesService: NodesService;
14
+ let nodesService: PhotosNodesAccess;
15
15
  let timeline: PhotosTimeline;
16
16
 
17
17
  const volumeId = 'volumeId';
@@ -2,7 +2,7 @@ import { DriveCrypto } from '../../crypto';
2
2
  import { Logger } from '../../interface';
3
3
  import { makeNodeUid } from '../uids';
4
4
  import { PhotosAPIService } from './apiService';
5
- import { NodesService } from './interface';
5
+ import { PhotosNodesAccess } from './nodes';
6
6
  import { PhotoSharesManager } from './shares';
7
7
 
8
8
  /**
@@ -14,7 +14,7 @@ export class PhotosTimeline {
14
14
  private apiService: PhotosAPIService,
15
15
  private driveCrypto: DriveCrypto,
16
16
  private photoShares: PhotoSharesManager,
17
- private nodesService: NodesService,
17
+ private nodesService: PhotosNodesAccess,
18
18
  ) {
19
19
  this.logger = logger;
20
20
  this.apiService = apiService;