@protontech/drive-sdk 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crypto/driveCrypto.d.ts +20 -2
- package/dist/crypto/driveCrypto.js +72 -14
- package/dist/crypto/driveCrypto.js.map +1 -1
- package/dist/crypto/driveCrypto.test.js +3 -2
- package/dist/crypto/driveCrypto.test.js.map +1 -1
- package/dist/crypto/interface.d.ts +22 -8
- package/dist/crypto/openPGPCrypto.d.ts +30 -7
- package/dist/crypto/openPGPCrypto.js +46 -8
- package/dist/crypto/openPGPCrypto.js.map +1 -1
- package/dist/diagnostic/telemetry.js +3 -0
- package/dist/diagnostic/telemetry.js.map +1 -1
- package/dist/interface/featureFlags.d.ts +4 -1
- package/dist/interface/featureFlags.js +5 -0
- package/dist/interface/featureFlags.js.map +1 -1
- package/dist/interface/index.d.ts +2 -0
- package/dist/interface/index.js +5 -1
- package/dist/interface/index.js.map +1 -1
- package/dist/interface/photos.d.ts +13 -1
- package/dist/interface/photos.js +14 -0
- package/dist/interface/photos.js.map +1 -1
- package/dist/interface/telemetry.d.ts +12 -1
- package/dist/interface/telemetry.js.map +1 -1
- package/dist/internal/apiService/apiService.d.ts +1 -1
- package/dist/internal/apiService/apiService.js +2 -2
- package/dist/internal/apiService/apiService.js.map +1 -1
- package/dist/internal/nodes/apiService.js.map +1 -1
- package/dist/internal/nodes/nodesAccess.js +1 -1
- package/dist/internal/nodes/nodesAccess.js.map +1 -1
- package/dist/internal/photos/addToAlbum.d.ts +1 -5
- package/dist/internal/photos/addToAlbum.js +8 -87
- package/dist/internal/photos/addToAlbum.js.map +1 -1
- package/dist/internal/photos/{albums.d.ts → albumsManager.d.ts} +1 -1
- package/dist/internal/photos/{albums.js → albumsManager.js} +4 -4
- package/dist/internal/photos/albumsManager.js.map +1 -0
- package/dist/internal/photos/{albums.test.js → albumsManager.test.js} +3 -3
- package/dist/internal/photos/albumsManager.test.js.map +1 -0
- package/dist/internal/photos/apiService.d.ts +8 -4
- package/dist/internal/photos/apiService.js +38 -6
- package/dist/internal/photos/apiService.js.map +1 -1
- package/dist/internal/photos/index.d.ts +7 -5
- package/dist/internal/photos/index.js +7 -4
- package/dist/internal/photos/index.js.map +1 -1
- package/dist/internal/photos/interface.d.ts +3 -26
- package/dist/internal/photos/interface.js +0 -14
- package/dist/internal/photos/interface.js.map +1 -1
- package/dist/internal/photos/nodes.js +1 -1
- package/dist/internal/photos/nodes.js.map +1 -1
- package/dist/internal/photos/photosManager.d.ts +22 -0
- package/dist/internal/photos/photosManager.js +101 -0
- package/dist/internal/photos/photosManager.js.map +1 -0
- package/dist/internal/photos/photosManager.test.d.ts +1 -0
- package/dist/internal/photos/photosManager.test.js +222 -0
- package/dist/internal/photos/photosManager.test.js.map +1 -0
- package/dist/internal/photos/photosTransferPayloadBuilder.d.ts +57 -0
- package/dist/internal/photos/photosTransferPayloadBuilder.js +113 -0
- package/dist/internal/photos/photosTransferPayloadBuilder.js.map +1 -0
- package/dist/internal/photos/photosTransferPayloadBuilder.test.d.ts +1 -0
- package/dist/internal/photos/photosTransferPayloadBuilder.test.js +289 -0
- package/dist/internal/photos/photosTransferPayloadBuilder.test.js.map +1 -0
- package/dist/internal/photos/upload.d.ts +2 -2
- package/dist/internal/photos/upload.js +3 -3
- package/dist/internal/photos/upload.js.map +1 -1
- package/dist/internal/sharingPublic/nodes.js +11 -4
- package/dist/internal/sharingPublic/nodes.js.map +1 -1
- package/dist/internal/upload/apiService.d.ts +0 -4
- package/dist/internal/upload/apiService.js +0 -4
- package/dist/internal/upload/apiService.js.map +1 -1
- package/dist/internal/upload/cryptoService.d.ts +4 -2
- package/dist/internal/upload/cryptoService.js +18 -6
- package/dist/internal/upload/cryptoService.js.map +1 -1
- package/dist/internal/upload/index.d.ts +2 -2
- package/dist/internal/upload/index.js +2 -2
- package/dist/internal/upload/index.js.map +1 -1
- package/dist/internal/upload/interface.d.ts +1 -1
- package/dist/internal/upload/streamUploader.d.ts +1 -1
- package/dist/internal/upload/streamUploader.js +12 -13
- package/dist/internal/upload/streamUploader.js.map +1 -1
- package/dist/internal/upload/streamUploader.test.js +28 -4
- package/dist/internal/upload/streamUploader.test.js.map +1 -1
- package/dist/protonDriveClient.js +2 -2
- package/dist/protonDriveClient.js.map +1 -1
- package/dist/protonDrivePhotosClient.d.ts +20 -3
- package/dist/protonDrivePhotosClient.js +27 -3
- package/dist/protonDrivePhotosClient.js.map +1 -1
- package/dist/protonDrivePublicLinkClient.d.ts +3 -2
- package/dist/protonDrivePublicLinkClient.js +7 -3
- package/dist/protonDrivePublicLinkClient.js.map +1 -1
- package/package.json +1 -1
- package/src/crypto/driveCrypto.test.ts +3 -1
- package/src/crypto/driveCrypto.ts +82 -7
- package/src/crypto/interface.ts +21 -8
- package/src/crypto/openPGPCrypto.ts +68 -8
- package/src/diagnostic/telemetry.ts +3 -0
- package/src/interface/featureFlags.ts +5 -1
- package/src/interface/index.ts +2 -0
- package/src/interface/photos.ts +14 -1
- package/src/interface/telemetry.ts +14 -1
- package/src/internal/apiService/apiService.ts +6 -2
- package/src/internal/nodes/apiService.ts +2 -2
- package/src/internal/nodes/nodesAccess.ts +1 -1
- package/src/internal/photos/addToAlbum.ts +29 -136
- package/src/internal/photos/{albums.test.ts → albumsManager.test.ts} +3 -3
- package/src/internal/photos/{albums.ts → albumsManager.ts} +1 -1
- package/src/internal/photos/apiService.ts +73 -16
- package/src/internal/photos/index.ts +9 -4
- package/src/internal/photos/interface.ts +3 -28
- package/src/internal/photos/nodes.ts +1 -1
- package/src/internal/photos/photosManager.test.ts +266 -0
- package/src/internal/photos/photosManager.ts +144 -0
- package/src/internal/photos/photosTransferPayloadBuilder.test.ts +380 -0
- package/src/internal/photos/photosTransferPayloadBuilder.ts +203 -0
- package/src/internal/photos/upload.ts +20 -7
- package/src/internal/sharingPublic/nodes.ts +11 -4
- package/src/internal/upload/apiService.ts +7 -9
- package/src/internal/upload/cryptoService.ts +28 -7
- package/src/internal/upload/index.ts +3 -2
- package/src/internal/upload/interface.ts +1 -1
- package/src/internal/upload/streamUploader.test.ts +33 -4
- package/src/internal/upload/streamUploader.ts +13 -13
- package/src/protonDriveClient.ts +2 -1
- package/src/protonDrivePhotosClient.ts +39 -2
- package/src/protonDrivePublicLinkClient.ts +9 -1
- package/dist/internal/photos/albums.js.map +0 -1
- package/dist/internal/photos/albums.test.js.map +0 -1
- /package/dist/internal/photos/{albums.test.d.ts → albumsManager.test.d.ts} +0 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { ValidationError } from '../../errors';
|
|
2
|
+
import { resultOk } from '../../interface';
|
|
3
|
+
import { AlbumsCryptoService } from './albumsCrypto';
|
|
4
|
+
import { DecryptedPhotoNode } from './interface';
|
|
5
|
+
import { PhotoTransferPayloadBuilder } from './photosTransferPayloadBuilder';
|
|
6
|
+
import { PhotosNodesAccess } from './nodes';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Helper to create a mock photo node with minimal required properties.
|
|
10
|
+
*/
|
|
11
|
+
function createMockPhotoNode(
|
|
12
|
+
uid: string,
|
|
13
|
+
overrides: Partial<DecryptedPhotoNode> = {},
|
|
14
|
+
): DecryptedPhotoNode {
|
|
15
|
+
return {
|
|
16
|
+
uid,
|
|
17
|
+
parentUid: 'volume1~parent',
|
|
18
|
+
hash: 'hash',
|
|
19
|
+
name: resultOk('photo.jpg'),
|
|
20
|
+
photo: {
|
|
21
|
+
captureTime: new Date(),
|
|
22
|
+
mainPhotoNodeUid: undefined,
|
|
23
|
+
relatedPhotoNodeUids: [],
|
|
24
|
+
tags: [],
|
|
25
|
+
albums: [],
|
|
26
|
+
},
|
|
27
|
+
activeRevision: {
|
|
28
|
+
ok: true,
|
|
29
|
+
value: {
|
|
30
|
+
uid: 'rev1',
|
|
31
|
+
state: 'active' as const,
|
|
32
|
+
creationTime: new Date(),
|
|
33
|
+
storageSize: 100,
|
|
34
|
+
signatureEmail: 'test@example.com',
|
|
35
|
+
claimedModificationTime: new Date(),
|
|
36
|
+
claimedSize: 100,
|
|
37
|
+
claimedDigests: { sha1: 'sha1hash' },
|
|
38
|
+
claimedBlockSizes: [100],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
keyAuthor: { ok: true, value: 'test@example.com' },
|
|
42
|
+
...overrides,
|
|
43
|
+
} as DecryptedPhotoNode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('PhotoTransferPayloadBuilder', () => {
|
|
47
|
+
let cryptoService: jest.Mocked<AlbumsCryptoService>;
|
|
48
|
+
let nodesService: jest.Mocked<PhotosNodesAccess>;
|
|
49
|
+
let targetKeys: { key: unknown; hashKey: Uint8Array };
|
|
50
|
+
let signingKeys: { type: 'userAddress'; email: string; addressId: string; key: unknown };
|
|
51
|
+
let builder: PhotoTransferPayloadBuilder;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
targetKeys = {
|
|
55
|
+
key: 'targetKey' as any,
|
|
56
|
+
hashKey: new Uint8Array([1, 2, 3]),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
signingKeys = {
|
|
60
|
+
type: 'userAddress',
|
|
61
|
+
email: 'test@example.com',
|
|
62
|
+
addressId: 'addressId',
|
|
63
|
+
key: 'signingKey' as any,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// @ts-expect-error Mocking for testing purposes
|
|
67
|
+
cryptoService = {
|
|
68
|
+
encryptPhotoForAlbum: jest.fn(),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// @ts-expect-error Mocking for testing purposes
|
|
72
|
+
nodesService = {
|
|
73
|
+
iterateNodes: jest.fn(),
|
|
74
|
+
getNodePrivateAndSessionKeys: jest.fn(),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
builder = new PhotoTransferPayloadBuilder(cryptoService, nodesService);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('preparePhotoPayloads', () => {
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
nodesService.iterateNodes.mockImplementation(async function* (uids: string[]) {
|
|
83
|
+
for (const uid of uids) {
|
|
84
|
+
if (uid === 'volume1~missing') {
|
|
85
|
+
yield { missingUid: uid };
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const photoNode = createMockPhotoNode(uid);
|
|
90
|
+
|
|
91
|
+
// Handle uids in the form 'volumeId~mainPhoto-related:N' where N is the number of related photos
|
|
92
|
+
const relatedMatch = /^(.+)~(.+)-related:(\d+)$/.exec(uid);
|
|
93
|
+
if (relatedMatch) {
|
|
94
|
+
const [, volumeId, , countStr] = relatedMatch;
|
|
95
|
+
const count = parseInt(countStr, 10);
|
|
96
|
+
photoNode.photo!.relatedPhotoNodeUids = Array.from(
|
|
97
|
+
{ length: count },
|
|
98
|
+
(_, idx) => `${volumeId}~related${idx + 1}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
yield photoNode;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
nodesService.getNodePrivateAndSessionKeys.mockResolvedValue({
|
|
107
|
+
key: 'nodeKey' as any,
|
|
108
|
+
nameSessionKey: 'sessionKey' as any,
|
|
109
|
+
passphrase: 'passphrase',
|
|
110
|
+
passphraseSessionKey: 'passphraseSessionKey' as any,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
cryptoService.encryptPhotoForAlbum.mockResolvedValue({
|
|
114
|
+
contentHash: 'contentHash',
|
|
115
|
+
hash: 'nameHash',
|
|
116
|
+
encryptedName: 'encryptedName',
|
|
117
|
+
nameSignatureEmail: 'test@example.com',
|
|
118
|
+
armoredNodePassphrase: 'passphrase',
|
|
119
|
+
armoredNodePassphraseSignature: 'signature',
|
|
120
|
+
signatureEmail: 'test@example.com',
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should return payloads and empty errors for a single photo without related photos', async () => {
|
|
125
|
+
const items = [{ photoNodeUid: 'volume1~photo1' }];
|
|
126
|
+
|
|
127
|
+
const result = await builder.preparePhotoPayloads(
|
|
128
|
+
items,
|
|
129
|
+
'volume1~root',
|
|
130
|
+
targetKeys as any,
|
|
131
|
+
signingKeys as any,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(result).toMatchObject({
|
|
135
|
+
payloads: [{
|
|
136
|
+
nodeUid: 'volume1~photo1',
|
|
137
|
+
contentHash: 'contentHash',
|
|
138
|
+
nameHash: 'nameHash',
|
|
139
|
+
encryptedName: 'encryptedName',
|
|
140
|
+
nameSignatureEmail: 'test@example.com',
|
|
141
|
+
nodePassphrase: 'passphrase',
|
|
142
|
+
relatedPhotos: [],
|
|
143
|
+
}],
|
|
144
|
+
errors: new Map(),
|
|
145
|
+
});
|
|
146
|
+
expect(nodesService.iterateNodes).toHaveBeenCalledWith(['volume1~photo1'], undefined);
|
|
147
|
+
expect(nodesService.getNodePrivateAndSessionKeys).toHaveBeenCalledWith('volume1~photo1');
|
|
148
|
+
expect(cryptoService.encryptPhotoForAlbum).toHaveBeenCalledTimes(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should include related photos in payload when photo has relatedPhotoNodeUids', async () => {
|
|
152
|
+
const items = [{ photoNodeUid: 'volume1~mainPhoto-related:3' }];
|
|
153
|
+
|
|
154
|
+
const result = await builder.preparePhotoPayloads(
|
|
155
|
+
items,
|
|
156
|
+
'volume1~root',
|
|
157
|
+
targetKeys as any,
|
|
158
|
+
signingKeys as any,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(result).toMatchObject({
|
|
162
|
+
payloads: [{
|
|
163
|
+
nodeUid: 'volume1~mainPhoto-related:3',
|
|
164
|
+
contentHash: 'contentHash',
|
|
165
|
+
nameHash: 'nameHash',
|
|
166
|
+
encryptedName: 'encryptedName',
|
|
167
|
+
nameSignatureEmail: 'test@example.com',
|
|
168
|
+
nodePassphrase: 'passphrase',
|
|
169
|
+
relatedPhotos: [{
|
|
170
|
+
nodeUid: 'volume1~related1',
|
|
171
|
+
contentHash: 'contentHash',
|
|
172
|
+
nameHash: 'nameHash',
|
|
173
|
+
encryptedName: 'encryptedName',
|
|
174
|
+
nameSignatureEmail: 'test@example.com',
|
|
175
|
+
nodePassphrase: 'passphrase',
|
|
176
|
+
}, {
|
|
177
|
+
nodeUid: 'volume1~related2',
|
|
178
|
+
contentHash: 'contentHash',
|
|
179
|
+
nameHash: 'nameHash',
|
|
180
|
+
encryptedName: 'encryptedName',
|
|
181
|
+
nameSignatureEmail: 'test@example.com',
|
|
182
|
+
nodePassphrase: 'passphrase',
|
|
183
|
+
}, {
|
|
184
|
+
nodeUid: 'volume1~related3',
|
|
185
|
+
contentHash: 'contentHash',
|
|
186
|
+
nameHash: 'nameHash',
|
|
187
|
+
encryptedName: 'encryptedName',
|
|
188
|
+
nameSignatureEmail: 'test@example.com',
|
|
189
|
+
nodePassphrase: 'passphrase',
|
|
190
|
+
}],
|
|
191
|
+
}],
|
|
192
|
+
errors: new Map(),
|
|
193
|
+
});
|
|
194
|
+
expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2);
|
|
195
|
+
expect(nodesService.iterateNodes).toHaveBeenNthCalledWith(1, ['volume1~mainPhoto-related:3'], undefined);
|
|
196
|
+
expect(nodesService.iterateNodes).toHaveBeenNthCalledWith(
|
|
197
|
+
2,
|
|
198
|
+
['volume1~related1', 'volume1~related2', 'volume1~related3'],
|
|
199
|
+
undefined,
|
|
200
|
+
);
|
|
201
|
+
expect(cryptoService.encryptPhotoForAlbum).toHaveBeenCalledTimes(4);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should merge additionalRelatedPhotoNodeUids with photo relatedPhotoNodeUids', async () => {
|
|
205
|
+
const items = [
|
|
206
|
+
{
|
|
207
|
+
photoNodeUid: 'volume1~photo1',
|
|
208
|
+
additionalRelatedPhotoNodeUids: ['volume1~extraRelated1'],
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
const result = await builder.preparePhotoPayloads(
|
|
213
|
+
items,
|
|
214
|
+
'volume1~root',
|
|
215
|
+
targetKeys as any,
|
|
216
|
+
signingKeys as any,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
expect(result).toMatchObject({
|
|
220
|
+
payloads: [{
|
|
221
|
+
nodeUid: 'volume1~photo1',
|
|
222
|
+
contentHash: 'contentHash',
|
|
223
|
+
nameHash: 'nameHash',
|
|
224
|
+
encryptedName: 'encryptedName',
|
|
225
|
+
nameSignatureEmail: 'test@example.com',
|
|
226
|
+
nodePassphrase: 'passphrase',
|
|
227
|
+
relatedPhotos: [{
|
|
228
|
+
nodeUid: 'volume1~extraRelated1',
|
|
229
|
+
contentHash: 'contentHash',
|
|
230
|
+
nameHash: 'nameHash',
|
|
231
|
+
encryptedName: 'encryptedName',
|
|
232
|
+
nameSignatureEmail: 'test@example.com',
|
|
233
|
+
nodePassphrase: 'passphrase',
|
|
234
|
+
}],
|
|
235
|
+
}],
|
|
236
|
+
errors: new Map(),
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should put missing node UIDs in errors with ValidationError', async () => {
|
|
241
|
+
const items = [
|
|
242
|
+
{ photoNodeUid: 'volume1~photo1' },
|
|
243
|
+
{ photoNodeUid: 'volume1~missing' },
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
const result = await builder.preparePhotoPayloads(
|
|
247
|
+
items,
|
|
248
|
+
'volume1~root',
|
|
249
|
+
targetKeys as any,
|
|
250
|
+
signingKeys as any,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(result).toMatchObject({
|
|
254
|
+
payloads: [{
|
|
255
|
+
nodeUid: 'volume1~photo1',
|
|
256
|
+
contentHash: 'contentHash',
|
|
257
|
+
nameHash: 'nameHash',
|
|
258
|
+
encryptedName: 'encryptedName',
|
|
259
|
+
nameSignatureEmail: 'test@example.com',
|
|
260
|
+
nodePassphrase: 'passphrase',
|
|
261
|
+
relatedPhotos: [],
|
|
262
|
+
}],
|
|
263
|
+
errors: new Map([['volume1~missing', new ValidationError('Photo not found')]]),
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should throw when targetKeys.hashKey is missing', async () => {
|
|
268
|
+
const items = [{ photoNodeUid: 'volume1~photo1' }];
|
|
269
|
+
const keysWithoutHashKey = { ...targetKeys, hashKey: undefined };
|
|
270
|
+
|
|
271
|
+
await expect(
|
|
272
|
+
builder.preparePhotoPayloads(items, 'volume1~root', keysWithoutHashKey as any, signingKeys as any),
|
|
273
|
+
).rejects.toThrow('Target hash key is required to build photo payloads');
|
|
274
|
+
|
|
275
|
+
expect(nodesService.iterateNodes).not.toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should put error in errors map when encryptPhotoForAlbum fails', async () => {
|
|
279
|
+
const items = [{ photoNodeUid: 'volume1~photo1' }];
|
|
280
|
+
const cryptoError = new Error('Crypto operation failed');
|
|
281
|
+
cryptoService.encryptPhotoForAlbum.mockRejectedValue(cryptoError);
|
|
282
|
+
|
|
283
|
+
const result = await builder.preparePhotoPayloads(
|
|
284
|
+
items,
|
|
285
|
+
'volume1~root',
|
|
286
|
+
targetKeys as any,
|
|
287
|
+
signingKeys as any,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
expect(result).toMatchObject({
|
|
291
|
+
payloads: [],
|
|
292
|
+
errors: new Map([['volume1~photo1', cryptoError]]),
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('should put error in errors map when getNodePrivateAndSessionKeys fails', async () => {
|
|
297
|
+
const items = [{ photoNodeUid: 'volume1~photo1' }];
|
|
298
|
+
const keysError = new Error('Failed to get keys');
|
|
299
|
+
nodesService.getNodePrivateAndSessionKeys.mockRejectedValue(keysError);
|
|
300
|
+
|
|
301
|
+
const result = await builder.preparePhotoPayloads(
|
|
302
|
+
items,
|
|
303
|
+
'volume1~root',
|
|
304
|
+
targetKeys as any,
|
|
305
|
+
signingKeys as any,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
expect(result).toMatchObject({
|
|
309
|
+
payloads: [],
|
|
310
|
+
errors: new Map([['volume1~photo1', keysError]]),
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should put error in errors map when photo has no content hash', async () => {
|
|
315
|
+
const items = [{ photoNodeUid: 'volume1~photo1' }];
|
|
316
|
+
nodesService.iterateNodes.mockImplementation(async function* (uids: string[]) {
|
|
317
|
+
const node = createMockPhotoNode(uids[0]);
|
|
318
|
+
node.activeRevision = { ok: true, value: { ...(node.activeRevision as any).value, claimedDigests: {} } } as any;
|
|
319
|
+
yield node;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const result = await builder.preparePhotoPayloads(
|
|
323
|
+
items,
|
|
324
|
+
'volume1~root',
|
|
325
|
+
targetKeys as any,
|
|
326
|
+
signingKeys as any,
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
expect(result).toMatchObject({
|
|
330
|
+
payloads: [],
|
|
331
|
+
errors: new Map([['volume1~photo1', new Error('Cannot build photo payload without a content hash')]]),
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('should include signatureEmail and nodePassphraseSignature only for anonymous key author', async () => {
|
|
336
|
+
const items = [{ photoNodeUid: 'volume1~anonymous' }, { photoNodeUid: 'volume1~signed' }];
|
|
337
|
+
nodesService.iterateNodes.mockImplementation(async function* (uids: string[]) {
|
|
338
|
+
for (const uid of uids) {
|
|
339
|
+
const node = createMockPhotoNode(uid);
|
|
340
|
+
if (uid === 'volume1~anonymous') {
|
|
341
|
+
node.keyAuthor = { ok: true, value: null };
|
|
342
|
+
} else {
|
|
343
|
+
node.keyAuthor = { ok: true, value: 'test@example.com' };
|
|
344
|
+
}
|
|
345
|
+
yield node;
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const result = await builder.preparePhotoPayloads(
|
|
350
|
+
items,
|
|
351
|
+
'volume1~root',
|
|
352
|
+
targetKeys as any,
|
|
353
|
+
signingKeys as any,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
expect(result).toMatchObject({
|
|
357
|
+
payloads: [{
|
|
358
|
+
nodeUid: 'volume1~anonymous',
|
|
359
|
+
contentHash: 'contentHash',
|
|
360
|
+
nameHash: 'nameHash',
|
|
361
|
+
encryptedName: 'encryptedName',
|
|
362
|
+
nameSignatureEmail: 'test@example.com',
|
|
363
|
+
nodePassphrase: 'passphrase',
|
|
364
|
+
signatureEmail: 'test@example.com',
|
|
365
|
+
nodePassphraseSignature: 'signature',
|
|
366
|
+
relatedPhotos: [],
|
|
367
|
+
}, {
|
|
368
|
+
nodeUid: 'volume1~signed',
|
|
369
|
+
contentHash: 'contentHash',
|
|
370
|
+
nameHash: 'nameHash',
|
|
371
|
+
encryptedName: 'encryptedName',
|
|
372
|
+
nameSignatureEmail: 'test@example.com',
|
|
373
|
+
nodePassphrase: 'passphrase',
|
|
374
|
+
relatedPhotos: [],
|
|
375
|
+
}],
|
|
376
|
+
errors: new Map(),
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { c } from 'ttag';
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '../../errors';
|
|
4
|
+
import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface';
|
|
5
|
+
import { AlbumsCryptoService } from './albumsCrypto';
|
|
6
|
+
import { DecryptedPhotoNode } from './interface';
|
|
7
|
+
import { PhotosNodesAccess } from './nodes';
|
|
8
|
+
|
|
9
|
+
export type TransferEncryptedPhotoPayload = TransferEncryptedRelatedPhotoPayload & {
|
|
10
|
+
relatedPhotos: TransferEncryptedRelatedPhotoPayload[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type TransferEncryptedRelatedPhotoPayload = {
|
|
14
|
+
nodeUid: string;
|
|
15
|
+
contentHash: string;
|
|
16
|
+
nameHash: string;
|
|
17
|
+
encryptedName: string;
|
|
18
|
+
nameSignatureEmail: string;
|
|
19
|
+
nodePassphrase: string;
|
|
20
|
+
nodePassphraseSignature?: string;
|
|
21
|
+
signatureEmail?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Item representing a photo to build a payload for.
|
|
26
|
+
* Used when preparing payloads for add-to-album (with optional retry related UIDs)
|
|
27
|
+
* or for favoriting.
|
|
28
|
+
*/
|
|
29
|
+
export type PhotoPayloadItem = {
|
|
30
|
+
photoNodeUid: string;
|
|
31
|
+
/**
|
|
32
|
+
* Additional related photo node UIDs to include (e.g. when retrying after
|
|
33
|
+
* MissingRelatedPhotosError).
|
|
34
|
+
*/
|
|
35
|
+
additionalRelatedPhotoNodeUids?: string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Builds encrypted photo payloads (TransferEncryptedPhotoPayload) for a set of
|
|
40
|
+
* photos, including their related photos. Reused by add-to-album and favorite
|
|
41
|
+
* flows, which only differ by the target keys used for encryption.
|
|
42
|
+
*/
|
|
43
|
+
export class PhotoTransferPayloadBuilder {
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly cryptoService: AlbumsCryptoService,
|
|
46
|
+
private readonly nodesService: PhotosNodesAccess,
|
|
47
|
+
) {}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Prepares encrypted payloads for the given photo items using the provided
|
|
51
|
+
* target keys and signing keys. Used for add-to-album (album keys) and
|
|
52
|
+
* favoriting (volume root keys).
|
|
53
|
+
*/
|
|
54
|
+
async preparePhotoPayloads(
|
|
55
|
+
items: PhotoPayloadItem[],
|
|
56
|
+
targetNodeUid: string,
|
|
57
|
+
targetKeys: DecryptedNodeKeys,
|
|
58
|
+
signingKeys: NodeSigningKeys,
|
|
59
|
+
signal?: AbortSignal,
|
|
60
|
+
): Promise<{
|
|
61
|
+
payloads: TransferEncryptedPhotoPayload[];
|
|
62
|
+
errors: Map<string, Error>;
|
|
63
|
+
}> {
|
|
64
|
+
const payloads: TransferEncryptedPhotoPayload[] = [];
|
|
65
|
+
const errors = new Map<string, Error>();
|
|
66
|
+
|
|
67
|
+
if (!targetKeys.hashKey) {
|
|
68
|
+
throw new Error('Target hash key is required to build photo payloads');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const additionalRelatedMap = new Map(
|
|
72
|
+
items.map((item) => [item.photoNodeUid, item.additionalRelatedPhotoNodeUids || []]),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const nodeUids = items.map((item) => item.photoNodeUid);
|
|
76
|
+
for await (const photoNode of this.nodesService.iterateNodes(nodeUids, signal)) {
|
|
77
|
+
if ('missingUid' in photoNode) {
|
|
78
|
+
errors.set(photoNode.missingUid, new ValidationError(c('Error').t`Photo not found`));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (photoNode.parentUid === targetNodeUid) {
|
|
83
|
+
errors.set(photoNode.uid, new PhotoAlreadyInTargetError());
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const additionalRelated = additionalRelatedMap.get(photoNode.uid) || [];
|
|
89
|
+
const payload = await this.preparePhotoPayload(
|
|
90
|
+
photoNode,
|
|
91
|
+
additionalRelated,
|
|
92
|
+
targetKeys,
|
|
93
|
+
signingKeys,
|
|
94
|
+
signal,
|
|
95
|
+
);
|
|
96
|
+
payloads.push(payload);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
errors.set(
|
|
99
|
+
photoNode.uid,
|
|
100
|
+
error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { payloads, errors };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async preparePhotoPayload(
|
|
109
|
+
photoNode: DecryptedPhotoNode,
|
|
110
|
+
additionalRelatedPhotoNodeUids: string[],
|
|
111
|
+
targetKeys: DecryptedNodeKeys,
|
|
112
|
+
signingKeys: NodeSigningKeys,
|
|
113
|
+
signal?: AbortSignal,
|
|
114
|
+
): Promise<TransferEncryptedPhotoPayload> {
|
|
115
|
+
const photoData = await this.encryptPhotoForTarget(photoNode, targetKeys, signingKeys);
|
|
116
|
+
|
|
117
|
+
const relatedNodeUids = [
|
|
118
|
+
...new Set([
|
|
119
|
+
...(photoNode.photo?.relatedPhotoNodeUids || []),
|
|
120
|
+
...additionalRelatedPhotoNodeUids,
|
|
121
|
+
]),
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const relatedPhotos =
|
|
125
|
+
relatedNodeUids.length > 0
|
|
126
|
+
? await this.prepareRelatedPhotoPayloads(relatedNodeUids, targetKeys, signingKeys, signal)
|
|
127
|
+
: [];
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
...photoData,
|
|
131
|
+
relatedPhotos,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private async prepareRelatedPhotoPayloads(
|
|
136
|
+
nodeUids: string[],
|
|
137
|
+
targetKeys: DecryptedNodeKeys,
|
|
138
|
+
signingKeys: NodeSigningKeys,
|
|
139
|
+
signal?: AbortSignal,
|
|
140
|
+
): Promise<Omit<TransferEncryptedPhotoPayload, 'relatedPhotos'>[]> {
|
|
141
|
+
const payloads: Omit<TransferEncryptedPhotoPayload, 'relatedPhotos'>[] = [];
|
|
142
|
+
|
|
143
|
+
for await (const photoNode of this.nodesService.iterateNodes(nodeUids, signal)) {
|
|
144
|
+
if ('missingUid' in photoNode) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const payload = await this.encryptPhotoForTarget(photoNode, targetKeys, signingKeys);
|
|
148
|
+
payloads.push(payload);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return payloads;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async encryptPhotoForTarget(
|
|
155
|
+
photoNode: DecryptedPhotoNode,
|
|
156
|
+
targetKeys: DecryptedNodeKeys,
|
|
157
|
+
signingKeys: NodeSigningKeys,
|
|
158
|
+
): Promise<Omit<TransferEncryptedPhotoPayload, 'relatedPhotos'>> {
|
|
159
|
+
const nodeKeys = await this.nodesService.getNodePrivateAndSessionKeys(photoNode.uid);
|
|
160
|
+
|
|
161
|
+
const contentSha1 = photoNode.activeRevision?.ok
|
|
162
|
+
? photoNode.activeRevision.value.claimedDigests?.sha1
|
|
163
|
+
: undefined;
|
|
164
|
+
|
|
165
|
+
if (!contentSha1) {
|
|
166
|
+
throw new Error('Cannot build photo payload without a content hash');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const encryptedCrypto = await this.cryptoService.encryptPhotoForAlbum(
|
|
170
|
+
photoNode.name,
|
|
171
|
+
contentSha1,
|
|
172
|
+
nodeKeys,
|
|
173
|
+
{ key: targetKeys.key, hashKey: targetKeys.hashKey! },
|
|
174
|
+
signingKeys,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const anonymousKey = photoNode.keyAuthor.ok && photoNode.keyAuthor.value === null;
|
|
178
|
+
const keySignatureProperties = !anonymousKey
|
|
179
|
+
? {}
|
|
180
|
+
: {
|
|
181
|
+
signatureEmail: encryptedCrypto.signatureEmail,
|
|
182
|
+
nodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
nodeUid: photoNode.uid,
|
|
187
|
+
contentHash: encryptedCrypto.contentHash,
|
|
188
|
+
nameHash: encryptedCrypto.hash,
|
|
189
|
+
encryptedName: encryptedCrypto.encryptedName,
|
|
190
|
+
nameSignatureEmail: encryptedCrypto.nameSignatureEmail,
|
|
191
|
+
nodePassphrase: encryptedCrypto.armoredNodePassphrase,
|
|
192
|
+
...keySignatureProperties,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export class PhotoAlreadyInTargetError extends ValidationError {
|
|
198
|
+
name = 'PhotoAlreadyInTargetError';
|
|
199
|
+
|
|
200
|
+
constructor() {
|
|
201
|
+
super(c('Error').t`Photo is already in the target album`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DriveCrypto } from '../../crypto';
|
|
2
|
-
import { ProtonDriveTelemetry, UploadMetadata, Thumbnail, AnonymousUser } from '../../interface';
|
|
2
|
+
import { ProtonDriveTelemetry, UploadMetadata, Thumbnail, AnonymousUser, FeatureFlagProvider } from '../../interface';
|
|
3
3
|
import { DriveAPIService, drivePaths } from '../apiService';
|
|
4
4
|
import { generateFileExtendedAttributes } from '../nodes';
|
|
5
5
|
import { splitNodeRevisionUid } from '../uids';
|
|
@@ -119,7 +119,12 @@ export class PhotoStreamUploader extends StreamUploader {
|
|
|
119
119
|
digests,
|
|
120
120
|
};
|
|
121
121
|
|
|
122
|
-
await this.photoUploadManager.commitDraftPhoto(
|
|
122
|
+
await this.photoUploadManager.commitDraftPhoto(
|
|
123
|
+
this.revisionDraft,
|
|
124
|
+
await this.getManifest(),
|
|
125
|
+
extendedAttributes,
|
|
126
|
+
this.photoMetadata,
|
|
127
|
+
);
|
|
123
128
|
}
|
|
124
129
|
}
|
|
125
130
|
|
|
@@ -157,7 +162,10 @@ export class PhotoUploadManager extends UploadManager {
|
|
|
157
162
|
}
|
|
158
163
|
|
|
159
164
|
// TODO: handle photo extended attributes in the SDK - now it must be passed from the client
|
|
160
|
-
const generatedExtendedAttributes = generateFileExtendedAttributes(
|
|
165
|
+
const generatedExtendedAttributes = generateFileExtendedAttributes(
|
|
166
|
+
extendedAttributes,
|
|
167
|
+
uploadMetadata.additionalMetadata,
|
|
168
|
+
);
|
|
161
169
|
const nodeCommitCrypto = await this.cryptoService.commitFile(
|
|
162
170
|
nodeRevisionDraft.nodeKeys,
|
|
163
171
|
manifest,
|
|
@@ -165,13 +173,16 @@ export class PhotoUploadManager extends UploadManager {
|
|
|
165
173
|
);
|
|
166
174
|
|
|
167
175
|
const sha1 = extendedAttributes.digests.sha1;
|
|
168
|
-
const contentHash = await this.photoCryptoService.generateContentHash(
|
|
176
|
+
const contentHash = await this.photoCryptoService.generateContentHash(
|
|
177
|
+
sha1,
|
|
178
|
+
nodeRevisionDraft.parentNodeKeys?.hashKey,
|
|
179
|
+
);
|
|
169
180
|
const photo = {
|
|
170
181
|
contentHash,
|
|
171
|
-
captureTime: uploadMetadata.captureTime ||
|
|
182
|
+
captureTime: uploadMetadata.captureTime || extendedAttributes.modificationTime,
|
|
172
183
|
mainPhotoLinkID: uploadMetadata.mainPhotoLinkID,
|
|
173
184
|
tags: uploadMetadata.tags,
|
|
174
|
-
}
|
|
185
|
+
};
|
|
175
186
|
await this.photoApiService.commitDraftPhoto(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto, photo);
|
|
176
187
|
await this.notifyNodeUploaded(nodeRevisionDraft);
|
|
177
188
|
}
|
|
@@ -179,10 +190,12 @@ export class PhotoUploadManager extends UploadManager {
|
|
|
179
190
|
|
|
180
191
|
export class PhotoUploadCryptoService extends UploadCryptoService {
|
|
181
192
|
constructor(
|
|
193
|
+
telemetry: ProtonDriveTelemetry,
|
|
182
194
|
driveCrypto: DriveCrypto,
|
|
183
195
|
nodesService: NodesService,
|
|
196
|
+
featureFlagProvider: FeatureFlagProvider,
|
|
184
197
|
) {
|
|
185
|
-
super(driveCrypto, nodesService);
|
|
198
|
+
super(telemetry, driveCrypto, nodesService, featureFlagProvider);
|
|
186
199
|
}
|
|
187
200
|
|
|
188
201
|
async generateContentHash(sha1: string, parentHashKey: Uint8Array<ArrayBuffer>): Promise<string> {
|
|
@@ -86,12 +86,19 @@ export class SharingPublicNodesAPIService extends NodeAPIService {
|
|
|
86
86
|
const nodeUid = makeNodeUid(volumeId, link.Link.LinkID);
|
|
87
87
|
const encryptedNode = linkToEncryptedNode(this.logger, volumeId, link, isOwnVolumeId);
|
|
88
88
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
89
|
+
// TODO: This affects the cache. At this moment, the public link is not cached
|
|
90
|
+
// anywhere, thus OK. To avoid issues when public links reuses the same cache,
|
|
91
|
+
// we need to move this either to the interface of given instance, or leave
|
|
92
|
+
// this as a responsibility to the client.
|
|
93
93
|
if (this.publicRootNodeUid === nodeUid) {
|
|
94
|
+
// Inject public permissions for the root node only.
|
|
95
|
+
// This ensures the root node has the correct directRole instead of
|
|
96
|
+
// incorrectly falling back to 'admin' due to null DirectPermissions.
|
|
94
97
|
encryptedNode.directRole = this.publicRole;
|
|
98
|
+
// This prevent to have parentUid in case user visited parent folder public link of a public link
|
|
99
|
+
// Since the session got permissions to get the parentNode,
|
|
100
|
+
// when visiting children it will return the parentLinkID in links request.
|
|
101
|
+
encryptedNode.parentUid = undefined;
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
return encryptedNode;
|