@protontech/drive-sdk 0.9.8 → 0.10.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 +15 -15
- package/dist/crypto/driveCrypto.js.map +1 -1
- package/dist/crypto/hmac.d.ts +3 -3
- package/dist/crypto/hmac.js.map +1 -1
- package/dist/crypto/interface.d.ts +45 -25
- package/dist/crypto/interface.js.map +1 -1
- package/dist/crypto/openPGPCrypto.d.ts +37 -37
- package/dist/crypto/openPGPCrypto.js.map +1 -1
- package/dist/crypto/utils.d.ts +1 -1
- package/dist/interface/index.d.ts +3 -3
- package/dist/interface/index.js.map +1 -1
- package/dist/interface/nodes.d.ts +8 -0
- package/dist/interface/photos.d.ts +18 -1
- package/dist/interface/sharing.d.ts +2 -0
- package/dist/interface/telemetry.d.ts +1 -0
- package/dist/interface/telemetry.js.map +1 -1
- package/dist/interface/thumbnail.d.ts +2 -2
- package/dist/internal/apiService/apiService.js +25 -12
- package/dist/internal/apiService/apiService.js.map +1 -1
- package/dist/internal/apiService/apiService.test.js +33 -5
- package/dist/internal/apiService/apiService.test.js.map +1 -1
- package/dist/internal/apiService/driveTypes.d.ts +2942 -3187
- package/dist/internal/apiService/errors.test.js +17 -7
- package/dist/internal/apiService/errors.test.js.map +1 -1
- package/dist/internal/devices/manager.d.ts +1 -0
- package/dist/internal/devices/manager.js +11 -0
- package/dist/internal/devices/manager.js.map +1 -1
- package/dist/internal/download/apiService.d.ts +1 -1
- package/dist/internal/download/cryptoService.d.ts +4 -4
- package/dist/internal/download/cryptoService.js.map +1 -1
- package/dist/internal/download/fileDownloader.js.map +1 -1
- package/dist/internal/download/fileDownloader.test.js.map +1 -1
- package/dist/internal/download/thumbnailDownloader.js.map +1 -1
- package/dist/internal/nodes/cryptoService.d.ts +4 -4
- package/dist/internal/nodes/cryptoService.js +5 -3
- package/dist/internal/nodes/cryptoService.js.map +1 -1
- package/dist/internal/nodes/cryptoService.test.js.map +1 -1
- package/dist/internal/nodes/interface.d.ts +1 -1
- package/dist/internal/nodes/nodesManagement.js +0 -1
- package/dist/internal/nodes/nodesManagement.js.map +1 -1
- package/dist/internal/photos/addToAlbum.d.ts +46 -0
- package/dist/internal/photos/addToAlbum.js +257 -0
- package/dist/internal/photos/addToAlbum.js.map +1 -0
- package/dist/internal/photos/addToAlbum.test.d.ts +1 -0
- package/dist/internal/photos/addToAlbum.test.js +409 -0
- package/dist/internal/photos/addToAlbum.test.js.map +1 -0
- package/dist/internal/photos/albums.d.ts +7 -2
- package/dist/internal/photos/albums.js +24 -1
- package/dist/internal/photos/albums.js.map +1 -1
- package/dist/internal/photos/albums.test.js +26 -1
- package/dist/internal/photos/albums.test.js.map +1 -1
- package/dist/internal/photos/albumsCrypto.d.ts +20 -3
- package/dist/internal/photos/albumsCrypto.js +27 -0
- package/dist/internal/photos/albumsCrypto.js.map +1 -1
- package/dist/internal/photos/apiService.d.ts +20 -0
- package/dist/internal/photos/apiService.js +142 -0
- package/dist/internal/photos/apiService.js.map +1 -1
- package/dist/internal/photos/apiService.test.d.ts +1 -0
- package/dist/internal/photos/apiService.test.js +199 -0
- package/dist/internal/photos/apiService.test.js.map +1 -0
- package/dist/internal/photos/errors.d.ts +4 -0
- package/dist/internal/photos/errors.js +17 -0
- package/dist/internal/photos/errors.js.map +1 -0
- package/dist/internal/photos/index.d.ts +1 -1
- package/dist/internal/photos/index.js +1 -1
- package/dist/internal/photos/index.js.map +1 -1
- package/dist/internal/photos/interface.d.ts +36 -1
- package/dist/internal/photos/interface.js +14 -0
- package/dist/internal/photos/interface.js.map +1 -1
- package/dist/internal/photos/nodes.js +32 -2
- package/dist/internal/photos/nodes.js.map +1 -1
- package/dist/internal/photos/nodes.test.js +25 -5
- package/dist/internal/photos/nodes.test.js.map +1 -1
- package/dist/internal/photos/timeline.d.ts +2 -5
- package/dist/internal/photos/timeline.js.map +1 -1
- package/dist/internal/photos/upload.d.ts +2 -2
- package/dist/internal/photos/upload.js.map +1 -1
- package/dist/internal/shares/apiService.js +1 -0
- package/dist/internal/shares/apiService.js.map +1 -1
- package/dist/internal/shares/interface.d.ts +1 -0
- package/dist/internal/sharing/apiService.d.ts +8 -1
- package/dist/internal/sharing/apiService.js +23 -1
- package/dist/internal/sharing/apiService.js.map +1 -1
- package/dist/internal/sharing/cryptoService.js +8 -4
- package/dist/internal/sharing/cryptoService.js.map +1 -1
- package/dist/internal/sharing/sharingManagement.d.ts +1 -0
- package/dist/internal/sharing/sharingManagement.js +15 -2
- package/dist/internal/sharing/sharingManagement.js.map +1 -1
- package/dist/internal/sharing/sharingManagement.test.js +30 -5
- package/dist/internal/sharing/sharingManagement.test.js.map +1 -1
- package/dist/internal/sharingPublic/nodes.d.ts +2 -2
- package/dist/internal/sharingPublic/nodes.js.map +1 -1
- package/dist/internal/upload/apiService.d.ts +5 -5
- package/dist/internal/upload/apiService.js.map +1 -1
- package/dist/internal/upload/blockVerifier.d.ts +2 -2
- package/dist/internal/upload/blockVerifier.js.map +1 -1
- package/dist/internal/upload/chunkStreamReader.d.ts +2 -2
- package/dist/internal/upload/chunkStreamReader.js.map +1 -1
- package/dist/internal/upload/chunkStreamReader.test.js.map +1 -1
- package/dist/internal/upload/cryptoService.d.ts +7 -7
- package/dist/internal/upload/cryptoService.js.map +1 -1
- package/dist/internal/upload/interface.d.ts +6 -6
- package/dist/internal/upload/manager.d.ts +1 -1
- package/dist/internal/upload/manager.js.map +1 -1
- package/dist/internal/upload/streamUploader.d.ts +1 -1
- package/dist/internal/utils.d.ts +1 -1
- package/dist/protonDriveClient.d.ts +8 -0
- package/dist/protonDriveClient.js +11 -0
- package/dist/protonDriveClient.js.map +1 -1
- package/dist/protonDrivePhotosClient.d.ts +42 -7
- package/dist/protonDrivePhotosClient.js +50 -2
- package/dist/protonDrivePhotosClient.js.map +1 -1
- package/dist/protonDrivePublicLinkClient.d.ts +9 -0
- package/dist/protonDrivePublicLinkClient.js +12 -0
- package/dist/protonDrivePublicLinkClient.js.map +1 -1
- package/dist/transformers.js +2 -0
- package/dist/transformers.js.map +1 -1
- package/package.json +4 -4
- package/src/crypto/driveCrypto.ts +15 -15
- package/src/crypto/hmac.ts +4 -4
- package/src/crypto/interface.ts +58 -27
- package/src/crypto/openPGPCrypto.ts +26 -26
- package/src/interface/index.ts +10 -2
- package/src/interface/nodes.ts +1 -0
- package/src/interface/photos.ts +19 -1
- package/src/interface/sharing.ts +2 -0
- package/src/interface/telemetry.ts +1 -0
- package/src/interface/thumbnail.ts +2 -2
- package/src/internal/apiService/apiService.test.ts +38 -6
- package/src/internal/apiService/apiService.ts +33 -12
- package/src/internal/apiService/driveTypes.ts +2942 -3187
- package/src/internal/devices/manager.ts +14 -0
- package/src/internal/download/apiService.ts +1 -1
- package/src/internal/download/cryptoService.ts +4 -4
- package/src/internal/download/fileDownloader.test.ts +4 -4
- package/src/internal/download/fileDownloader.ts +6 -6
- package/src/internal/download/thumbnailDownloader.ts +4 -4
- package/src/internal/nodes/cryptoService.test.ts +2 -2
- package/src/internal/nodes/cryptoService.ts +11 -8
- package/src/internal/nodes/interface.ts +1 -1
- package/src/internal/nodes/nodesManagement.ts +0 -1
- package/src/internal/photos/addToAlbum.test.ts +515 -0
- package/src/internal/photos/addToAlbum.ts +341 -0
- package/src/internal/photos/albums.test.ts +46 -22
- package/src/internal/photos/albums.ts +48 -2
- package/src/internal/photos/albumsCrypto.ts +54 -3
- package/src/internal/photos/apiService.test.ts +233 -0
- package/src/internal/photos/apiService.ts +234 -15
- package/src/internal/photos/errors.ts +11 -0
- package/src/internal/photos/index.ts +2 -2
- package/src/internal/photos/interface.ts +40 -1
- package/src/internal/photos/nodes.test.ts +27 -6
- package/src/internal/photos/nodes.ts +34 -2
- package/src/internal/photos/timeline.ts +2 -5
- package/src/internal/photos/upload.ts +2 -2
- package/src/internal/shares/apiService.ts +1 -0
- package/src/internal/shares/interface.ts +1 -0
- package/src/internal/sharing/apiService.ts +49 -5
- package/src/internal/sharing/cryptoService.ts +10 -4
- package/src/internal/sharing/sharingManagement.test.ts +33 -5
- package/src/internal/sharing/sharingManagement.ts +28 -6
- package/src/internal/sharingPublic/nodes.ts +1 -1
- package/src/internal/upload/apiService.ts +5 -5
- package/src/internal/upload/blockVerifier.ts +3 -3
- package/src/internal/upload/chunkStreamReader.test.ts +7 -7
- package/src/internal/upload/chunkStreamReader.ts +3 -3
- package/src/internal/upload/cryptoService.ts +9 -9
- package/src/internal/upload/interface.ts +6 -6
- package/src/internal/upload/manager.ts +2 -2
- package/src/internal/upload/streamUploader.ts +1 -1
- package/src/protonDriveClient.ts +15 -3
- package/src/protonDrivePhotosClient.ts +78 -22
- package/src/protonDrivePublicLinkClient.ts +13 -0
- package/src/transformers.ts +2 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { c } from 'ttag';
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '../../errors';
|
|
4
|
+
import { Logger, NodeResultWithError } from '../../interface';
|
|
5
|
+
import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface';
|
|
6
|
+
import { splitNodeUid } from '../uids';
|
|
7
|
+
import { AlbumsCryptoService } from './albumsCrypto';
|
|
8
|
+
import { PhotosAPIService } from './apiService';
|
|
9
|
+
import { MissingRelatedPhotosError } from './errors';
|
|
10
|
+
import { AddToAlbumEncryptedPhotoPayload, DecryptedPhotoNode } from './interface';
|
|
11
|
+
import { PhotosNodesAccess } from './nodes';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The number of photos that are loaded in parallel to prepare the payloads.
|
|
15
|
+
*/
|
|
16
|
+
const BATCH_LOADING_SIZE = 20;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The maximum number of photos that can be added to an album in a single
|
|
20
|
+
* request. The size includes the photo itself and its related photos.
|
|
21
|
+
*/
|
|
22
|
+
const ADD_PHOTOS_BATCH_SIZE = 10;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Item in the processing queue representing a photo to add to an album.
|
|
26
|
+
*/
|
|
27
|
+
type PhotoQueueItem = {
|
|
28
|
+
photoNodeUid: string;
|
|
29
|
+
/**
|
|
30
|
+
* When retrying after a MissingRelatedPhotosError, these contain the
|
|
31
|
+
* node UIDs reported as missing by the server that need to be included
|
|
32
|
+
* as additional related photos.
|
|
33
|
+
*/
|
|
34
|
+
additionalRelatedPhotoNodeUids: string[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Manages the process of adding photos to an album.
|
|
39
|
+
*
|
|
40
|
+
* Photos are split into two queues based on volume:
|
|
41
|
+
* - Same volume: added in batches via the add-multiple endpoint.
|
|
42
|
+
* - Different volume: copied individually via the copy endpoint.
|
|
43
|
+
*
|
|
44
|
+
* Both paths handle MissingRelatedPhotosError by re-queuing the failed
|
|
45
|
+
* photo with updated related photo UIDs for one retry attempt.
|
|
46
|
+
*/
|
|
47
|
+
export class AddToAlbumProcess {
|
|
48
|
+
private readonly albumVolumeId: string;
|
|
49
|
+
private readonly retriedPhotoUids = new Set<string>();
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly albumNodeUid: string,
|
|
53
|
+
private readonly albumKeys: DecryptedNodeKeys,
|
|
54
|
+
private readonly signingKeys: NodeSigningKeys,
|
|
55
|
+
private readonly apiService: PhotosAPIService,
|
|
56
|
+
private readonly cryptoService: AlbumsCryptoService,
|
|
57
|
+
private readonly nodesService: PhotosNodesAccess,
|
|
58
|
+
private readonly logger: Logger,
|
|
59
|
+
private readonly signal?: AbortSignal,
|
|
60
|
+
) {
|
|
61
|
+
this.albumVolumeId = splitNodeUid(albumNodeUid).volumeId;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async *execute(photoNodeUids: string[]): AsyncGenerator<NodeResultWithError> {
|
|
65
|
+
const { sameVolumeQueue, differentVolumeQueue } = splitByVolume(photoNodeUids, this.albumVolumeId);
|
|
66
|
+
|
|
67
|
+
yield* this.processSameVolumeQueue(sameVolumeQueue);
|
|
68
|
+
yield* this.processDifferentVolumeQueue(differentVolumeQueue);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async *processSameVolumeQueue(queue: PhotoQueueItem[]): AsyncGenerator<NodeResultWithError> {
|
|
72
|
+
while (queue.length > 0) {
|
|
73
|
+
const items = queue.splice(0, BATCH_LOADING_SIZE);
|
|
74
|
+
const { payloads, errors } = await this.preparePhotoPayloads(items);
|
|
75
|
+
|
|
76
|
+
for (const [uid, error] of errors) {
|
|
77
|
+
yield { uid, ok: false, error };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const batch of createBatches(payloads)) {
|
|
81
|
+
for await (const result of this.apiService.addPhotosToAlbum(this.albumNodeUid, batch, this.signal)) {
|
|
82
|
+
const retryItem = this.handleMissingRelatedPhotosError(result);
|
|
83
|
+
if (retryItem) {
|
|
84
|
+
queue.push(retryItem);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (result.ok) {
|
|
89
|
+
await this.nodesService.notifyNodeChanged(result.uid);
|
|
90
|
+
}
|
|
91
|
+
yield result;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async *processDifferentVolumeQueue(queue: PhotoQueueItem[]): AsyncGenerator<NodeResultWithError> {
|
|
98
|
+
while (queue.length > 0) {
|
|
99
|
+
const items = queue.splice(0, BATCH_LOADING_SIZE);
|
|
100
|
+
const { payloads, errors } = await this.preparePhotoPayloads(items);
|
|
101
|
+
|
|
102
|
+
for (const [uid, error] of errors) {
|
|
103
|
+
yield { uid, ok: false, error };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const payload of payloads) {
|
|
107
|
+
try {
|
|
108
|
+
const newPhotoNodeUid = await this.apiService.copyPhotoToAlbum(this.albumNodeUid, payload, this.signal);
|
|
109
|
+
await this.nodesService.notifyChildCreated(newPhotoNodeUid);
|
|
110
|
+
yield { uid: payload.nodeUid, ok: true };
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof MissingRelatedPhotosError) {
|
|
113
|
+
const retryItem = this.createRetryQueueItem(payload.nodeUid, error);
|
|
114
|
+
if (retryItem) {
|
|
115
|
+
queue.push(retryItem);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
yield {
|
|
120
|
+
uid: payload.nodeUid,
|
|
121
|
+
ok: false,
|
|
122
|
+
error: error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async preparePhotoPayloads(items: PhotoQueueItem[]): Promise<{
|
|
130
|
+
payloads: AddToAlbumEncryptedPhotoPayload[];
|
|
131
|
+
errors: Map<string, Error>;
|
|
132
|
+
}> {
|
|
133
|
+
const payloads: AddToAlbumEncryptedPhotoPayload[] = [];
|
|
134
|
+
const errors = new Map<string, Error>();
|
|
135
|
+
|
|
136
|
+
const additionalRelatedMap = new Map(
|
|
137
|
+
items.map((item) => [item.photoNodeUid, item.additionalRelatedPhotoNodeUids]),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const nodeUids = items.map((item) => item.photoNodeUid);
|
|
141
|
+
for await (const photoNode of this.nodesService.iterateNodes(nodeUids, this.signal)) {
|
|
142
|
+
if ('missingUid' in photoNode) {
|
|
143
|
+
errors.set(photoNode.missingUid, new ValidationError(c('Error').t`Photo not found`));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const additionalRelated = additionalRelatedMap.get(photoNode.uid) || [];
|
|
149
|
+
const payload = await this.preparePhotoPayload(photoNode, additionalRelated);
|
|
150
|
+
payloads.push(payload);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
errors.set(
|
|
153
|
+
photoNode.uid,
|
|
154
|
+
error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { payloads, errors };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async preparePhotoPayload(
|
|
163
|
+
photoNode: DecryptedPhotoNode,
|
|
164
|
+
additionalRelatedPhotoNodeUids: string[],
|
|
165
|
+
): Promise<AddToAlbumEncryptedPhotoPayload> {
|
|
166
|
+
const photoData = await this.encryptPhotoForAlbum(photoNode);
|
|
167
|
+
|
|
168
|
+
const relatedNodeUids = [...new Set([
|
|
169
|
+
...(photoNode.photo?.relatedPhotoNodeUids || []),
|
|
170
|
+
...additionalRelatedPhotoNodeUids,
|
|
171
|
+
])];
|
|
172
|
+
|
|
173
|
+
const relatedPhotos =
|
|
174
|
+
relatedNodeUids.length > 0 ? await this.prepareRelatedPhotoPayloads(relatedNodeUids) : [];
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
...photoData,
|
|
178
|
+
relatedPhotos,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private async prepareRelatedPhotoPayloads(
|
|
183
|
+
nodeUids: string[],
|
|
184
|
+
): Promise<Omit<AddToAlbumEncryptedPhotoPayload, 'relatedPhotos'>[]> {
|
|
185
|
+
const payloads: Omit<AddToAlbumEncryptedPhotoPayload, 'relatedPhotos'>[] = [];
|
|
186
|
+
|
|
187
|
+
for await (const photoNode of this.nodesService.iterateNodes(nodeUids, this.signal)) {
|
|
188
|
+
// Missing related photos means that the related photo was deleted
|
|
189
|
+
// since the loading of the metadata. It can happen and should be
|
|
190
|
+
// ignored. The backend controls all the related photos are part
|
|
191
|
+
// of the request, thus the request will fail and be retried if
|
|
192
|
+
// there is any other race condition.
|
|
193
|
+
if ('missingUid' in photoNode) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const payload = await this.encryptPhotoForAlbum(photoNode);
|
|
197
|
+
payloads.push(payload);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return payloads;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async encryptPhotoForAlbum(
|
|
204
|
+
photoNode: DecryptedPhotoNode,
|
|
205
|
+
): Promise<AddToAlbumEncryptedPhotoPayload> {
|
|
206
|
+
const nodeKeys = await this.nodesService.getNodePrivateAndSessionKeys(photoNode.uid);
|
|
207
|
+
|
|
208
|
+
const contentSha1 = photoNode.activeRevision?.ok
|
|
209
|
+
? photoNode.activeRevision.value.claimedDigests?.sha1
|
|
210
|
+
: undefined;
|
|
211
|
+
|
|
212
|
+
if (!contentSha1) {
|
|
213
|
+
throw new Error('Cannot add photo to album without a content hash');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const encryptedCrypto = await this.cryptoService.encryptPhotoForAlbum(
|
|
217
|
+
photoNode.name,
|
|
218
|
+
contentSha1,
|
|
219
|
+
nodeKeys,
|
|
220
|
+
{ key: this.albumKeys.key, hashKey: this.albumKeys.hashKey! },
|
|
221
|
+
this.signingKeys,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Node could be uploaded or renamed by anonymous user and thus have
|
|
225
|
+
// missing signatures that must be added to the request.
|
|
226
|
+
// Node passphrase and signature email must be passed if and only if
|
|
227
|
+
// the signatures are missing (key author is null).
|
|
228
|
+
const anonymousKey = photoNode.keyAuthor.ok && photoNode.keyAuthor.value === null;
|
|
229
|
+
const keySignatureProperties = !anonymousKey
|
|
230
|
+
? {}
|
|
231
|
+
: {
|
|
232
|
+
signatureEmail: encryptedCrypto.signatureEmail,
|
|
233
|
+
nodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
nodeUid: photoNode.uid,
|
|
238
|
+
contentHash: encryptedCrypto.contentHash,
|
|
239
|
+
nameHash: encryptedCrypto.hash,
|
|
240
|
+
encryptedName: encryptedCrypto.encryptedName,
|
|
241
|
+
nameSignatureEmail: encryptedCrypto.nameSignatureEmail,
|
|
242
|
+
nodePassphrase: encryptedCrypto.armoredNodePassphrase,
|
|
243
|
+
...keySignatureProperties,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* If the result indicates a MissingRelatedPhotosError that hasn't
|
|
249
|
+
* been retried, returns a retry queue item. Otherwise returns undefined.
|
|
250
|
+
*/
|
|
251
|
+
private handleMissingRelatedPhotosError(result: NodeResultWithError): PhotoQueueItem | undefined {
|
|
252
|
+
if (!result.ok && result.error instanceof MissingRelatedPhotosError) {
|
|
253
|
+
return this.createRetryQueueItem(result.uid, result.error);
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Creates a retry queue item with the missing related photo UIDs.
|
|
260
|
+
* Returns undefined if the photo has already been retried, preventing
|
|
261
|
+
* infinite retry loops.
|
|
262
|
+
*/
|
|
263
|
+
private createRetryQueueItem(
|
|
264
|
+
photoNodeUid: string,
|
|
265
|
+
error: MissingRelatedPhotosError,
|
|
266
|
+
): PhotoQueueItem | undefined {
|
|
267
|
+
if (this.retriedPhotoUids.has(photoNodeUid)) {
|
|
268
|
+
this.logger.warn(`Missing related photos for ${photoNodeUid}, already retried`);
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
this.retriedPhotoUids.add(photoNodeUid);
|
|
273
|
+
this.logger.info(
|
|
274
|
+
`Missing related photos for ${photoNodeUid}, re-queuing: ${error.missingNodeUids.join(', ')}`,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
photoNodeUid,
|
|
279
|
+
additionalRelatedPhotoNodeUids: error.missingNodeUids,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Splits photo UIDs into same-volume and different-volume queues
|
|
286
|
+
* based on the album's volume ID.
|
|
287
|
+
*/
|
|
288
|
+
function splitByVolume(
|
|
289
|
+
photoNodeUids: string[],
|
|
290
|
+
albumVolumeId: string,
|
|
291
|
+
): {
|
|
292
|
+
sameVolumeQueue: PhotoQueueItem[];
|
|
293
|
+
differentVolumeQueue: PhotoQueueItem[];
|
|
294
|
+
} {
|
|
295
|
+
const sameVolumeQueue: PhotoQueueItem[] = [];
|
|
296
|
+
const differentVolumeQueue: PhotoQueueItem[] = [];
|
|
297
|
+
|
|
298
|
+
for (const photoNodeUid of photoNodeUids) {
|
|
299
|
+
const { volumeId } = splitNodeUid(photoNodeUid);
|
|
300
|
+
const item: PhotoQueueItem = {
|
|
301
|
+
photoNodeUid,
|
|
302
|
+
additionalRelatedPhotoNodeUids: [],
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
if (volumeId === albumVolumeId) {
|
|
306
|
+
sameVolumeQueue.push(item);
|
|
307
|
+
} else {
|
|
308
|
+
differentVolumeQueue.push(item);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return { sameVolumeQueue, differentVolumeQueue };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Groups payloads into batches respecting the API limit.
|
|
317
|
+
* Each payload's size counts itself plus its related photos.
|
|
318
|
+
*/
|
|
319
|
+
function* createBatches(
|
|
320
|
+
payloads: AddToAlbumEncryptedPhotoPayload[],
|
|
321
|
+
): Generator<AddToAlbumEncryptedPhotoPayload[]> {
|
|
322
|
+
let batch: AddToAlbumEncryptedPhotoPayload[] = [];
|
|
323
|
+
let batchSize = 0;
|
|
324
|
+
|
|
325
|
+
for (const payload of payloads) {
|
|
326
|
+
const payloadSize = 1 + (payload.relatedPhotos?.length || 0);
|
|
327
|
+
|
|
328
|
+
if (batch.length > 0 && batchSize + payloadSize > ADD_PHOTOS_BATCH_SIZE) {
|
|
329
|
+
yield batch;
|
|
330
|
+
batch = [];
|
|
331
|
+
batchSize = 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
batch.push(payload);
|
|
335
|
+
batchSize += payloadSize;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (batch.length > 0) {
|
|
339
|
+
yield batch;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { NodeType
|
|
1
|
+
import { NodeType } from '../../interface';
|
|
2
2
|
import { ValidationError } from '../../errors';
|
|
3
|
+
import { getMockTelemetry } from '../../tests/telemetry';
|
|
3
4
|
import { Albums } from './albums';
|
|
4
5
|
import { AlbumsCryptoService } from './albumsCrypto';
|
|
5
6
|
import { PhotosAPIService } from './apiService';
|
|
@@ -37,6 +38,7 @@ describe('Albums', () => {
|
|
|
37
38
|
createAlbum: jest.fn().mockResolvedValue('volumeId~newAlbumNodeId'),
|
|
38
39
|
updateAlbum: jest.fn(),
|
|
39
40
|
deleteAlbum: jest.fn(),
|
|
41
|
+
removePhotosFromAlbum: jest.fn(),
|
|
40
42
|
};
|
|
41
43
|
|
|
42
44
|
// @ts-expect-error No need to implement all methods for mocking
|
|
@@ -95,7 +97,7 @@ describe('Albums', () => {
|
|
|
95
97
|
notifyChildCreated: jest.fn(),
|
|
96
98
|
};
|
|
97
99
|
|
|
98
|
-
albums = new Albums(apiService, cryptoService, photoShares, nodesService);
|
|
100
|
+
albums = new Albums(getMockTelemetry(), apiService, cryptoService, photoShares, nodesService);
|
|
99
101
|
});
|
|
100
102
|
|
|
101
103
|
describe('createAlbum', () => {
|
|
@@ -171,16 +173,12 @@ describe('Albums', () => {
|
|
|
171
173
|
{ type: 'userAddress', email: 'user@example.com', addressId: 'addressId', key: 'addressKey' },
|
|
172
174
|
'new album name',
|
|
173
175
|
);
|
|
174
|
-
expect(apiService.updateAlbum).toHaveBeenCalledWith(
|
|
175
|
-
'
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
originalHash: 'albumHash',
|
|
181
|
-
nameSignatureEmail: 'newSignatureEmail',
|
|
182
|
-
},
|
|
183
|
-
);
|
|
176
|
+
expect(apiService.updateAlbum).toHaveBeenCalledWith('albumNodeUid', undefined, {
|
|
177
|
+
encryptedName: 'newArmoredAlbumName',
|
|
178
|
+
hash: 'newHash',
|
|
179
|
+
originalHash: 'albumHash',
|
|
180
|
+
nameSignatureEmail: 'newSignatureEmail',
|
|
181
|
+
});
|
|
184
182
|
expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('albumNodeUid');
|
|
185
183
|
});
|
|
186
184
|
|
|
@@ -206,16 +204,12 @@ describe('Albums', () => {
|
|
|
206
204
|
nameAuthor: { ok: true, value: 'newSignatureEmail' },
|
|
207
205
|
hash: 'newHash',
|
|
208
206
|
});
|
|
209
|
-
expect(apiService.updateAlbum).toHaveBeenCalledWith(
|
|
210
|
-
'
|
|
211
|
-
'
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
originalHash: 'albumHash',
|
|
216
|
-
nameSignatureEmail: 'newSignatureEmail',
|
|
217
|
-
},
|
|
218
|
-
);
|
|
207
|
+
expect(apiService.updateAlbum).toHaveBeenCalledWith('albumNodeUid', 'photoNodeUid', {
|
|
208
|
+
encryptedName: 'newArmoredAlbumName',
|
|
209
|
+
hash: 'newHash',
|
|
210
|
+
originalHash: 'albumHash',
|
|
211
|
+
nameSignatureEmail: 'newSignatureEmail',
|
|
212
|
+
});
|
|
219
213
|
});
|
|
220
214
|
|
|
221
215
|
it('throws validation error for invalid album name', async () => {
|
|
@@ -238,4 +232,34 @@ describe('Albums', () => {
|
|
|
238
232
|
expect(nodesService.notifyNodeDeleted).toHaveBeenCalledWith('albumNodeUid');
|
|
239
233
|
});
|
|
240
234
|
});
|
|
235
|
+
|
|
236
|
+
describe('removePhotos', () => {
|
|
237
|
+
it('notifies nodes service only for successfully removed photos', async () => {
|
|
238
|
+
apiService.removePhotosFromAlbum = jest.fn().mockImplementation(async function* () {
|
|
239
|
+
yield { uid: 'photo1', ok: true };
|
|
240
|
+
yield { uid: 'photo2', ok: false, error: 'Some error' };
|
|
241
|
+
yield { uid: 'photo3', ok: true };
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const results = [];
|
|
245
|
+
for await (const result of albums.removePhotos('albumNodeUid', ['photo1', 'photo2', 'photo3'])) {
|
|
246
|
+
results.push(result);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
expect(results).toEqual([
|
|
250
|
+
{ uid: 'photo1', ok: true },
|
|
251
|
+
{ uid: 'photo2', ok: false, error: 'Some error' },
|
|
252
|
+
{ uid: 'photo3', ok: true },
|
|
253
|
+
]);
|
|
254
|
+
expect(apiService.removePhotosFromAlbum).toHaveBeenCalledWith(
|
|
255
|
+
'albumNodeUid',
|
|
256
|
+
['photo1', 'photo2', 'photo3'],
|
|
257
|
+
undefined,
|
|
258
|
+
);
|
|
259
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(2);
|
|
260
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('photo1');
|
|
261
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('photo3');
|
|
262
|
+
expect(nodesService.notifyNodeChanged).not.toHaveBeenCalledWith('photo2');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
241
265
|
});
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { MemberRole, NodeType, resultOk } from '../../interface';
|
|
1
|
+
import { Logger, MemberRole, NodeResultWithError, NodeType, ProtonDriveTelemetry, resultOk } from '../../interface';
|
|
2
2
|
import { BatchLoading } from '../batchLoading';
|
|
3
3
|
import { DecryptedNode } from '../nodes';
|
|
4
4
|
import { ALBUM_MEDIA_TYPE } from '../nodes/mediaTypes';
|
|
5
5
|
import { validateNodeName } from '../nodes/validations';
|
|
6
6
|
import { splitNodeUid } from '../uids';
|
|
7
|
+
import { AddToAlbumProcess } from './addToAlbum';
|
|
7
8
|
import { AlbumsCryptoService } from './albumsCrypto';
|
|
8
9
|
import { PhotosAPIService } from './apiService';
|
|
9
|
-
import { DecryptedPhotoNode } from './interface';
|
|
10
|
+
import { AlbumItem, DecryptedPhotoNode } from './interface';
|
|
10
11
|
import { PhotosNodesAccess } from './nodes';
|
|
11
12
|
import { PhotoSharesManager } from './shares';
|
|
12
13
|
|
|
@@ -16,12 +17,16 @@ const BATCH_LOADING_SIZE = 10;
|
|
|
16
17
|
* Provides access and high-level actions for managing albums.
|
|
17
18
|
*/
|
|
18
19
|
export class Albums {
|
|
20
|
+
private logger: Logger;
|
|
21
|
+
|
|
19
22
|
constructor(
|
|
23
|
+
telemetry: ProtonDriveTelemetry,
|
|
20
24
|
private apiService: PhotosAPIService,
|
|
21
25
|
private cryptoService: AlbumsCryptoService,
|
|
22
26
|
private photoShares: PhotoSharesManager,
|
|
23
27
|
private nodesService: PhotosNodesAccess,
|
|
24
28
|
) {
|
|
29
|
+
this.logger = telemetry.getLogger('albums');
|
|
25
30
|
this.apiService = apiService;
|
|
26
31
|
this.cryptoService = cryptoService;
|
|
27
32
|
this.photoShares = photoShares;
|
|
@@ -41,6 +46,10 @@ export class Albums {
|
|
|
41
46
|
yield* batchLoading.loadRest();
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
async *iterateAlbum(albumNodeUid: string, signal?: AbortSignal): AsyncGenerator<AlbumItem> {
|
|
50
|
+
yield* this.apiService.iterateAlbumChildren(albumNodeUid, signal);
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
async createAlbum(name: string): Promise<DecryptedPhotoNode> {
|
|
45
54
|
validateNodeName(name);
|
|
46
55
|
|
|
@@ -152,6 +161,43 @@ export class Albums {
|
|
|
152
161
|
await this.nodesService.notifyNodeDeleted(nodeUid);
|
|
153
162
|
}
|
|
154
163
|
|
|
164
|
+
async *addPhotos(
|
|
165
|
+
albumNodeUid: string,
|
|
166
|
+
photoNodeUids: string[],
|
|
167
|
+
signal?: AbortSignal,
|
|
168
|
+
): AsyncGenerator<NodeResultWithError> {
|
|
169
|
+
const albumKeys = await this.nodesService.getNodeKeys(albumNodeUid);
|
|
170
|
+
if (!albumKeys.hashKey) {
|
|
171
|
+
throw new Error('Cannot add photos to album: album hash key not available');
|
|
172
|
+
}
|
|
173
|
+
const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid: albumNodeUid });
|
|
174
|
+
|
|
175
|
+
const process = new AddToAlbumProcess(
|
|
176
|
+
albumNodeUid,
|
|
177
|
+
albumKeys,
|
|
178
|
+
signingKeys,
|
|
179
|
+
this.apiService,
|
|
180
|
+
this.cryptoService,
|
|
181
|
+
this.nodesService,
|
|
182
|
+
this.logger,
|
|
183
|
+
signal,
|
|
184
|
+
);
|
|
185
|
+
yield * process.execute(photoNodeUids);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async *removePhotos(
|
|
189
|
+
albumNodeUid: string,
|
|
190
|
+
photoNodeUids: string[],
|
|
191
|
+
signal?: AbortSignal,
|
|
192
|
+
): AsyncGenerator<NodeResultWithError> {
|
|
193
|
+
for await (const result of this.apiService.removePhotosFromAlbum(albumNodeUid, photoNodeUids, signal)) {
|
|
194
|
+
if (result.ok) {
|
|
195
|
+
await this.nodesService.notifyNodeChanged(result.uid);
|
|
196
|
+
}
|
|
197
|
+
yield result;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
155
201
|
private async *iterateNodesAndIgnoreMissingOnes(
|
|
156
202
|
nodeUids: string[],
|
|
157
203
|
signal?: AbortSignal,
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { c } from 'ttag';
|
|
2
|
+
import { DriveCrypto, PrivateKey, SessionKey } from '../../crypto';
|
|
3
|
+
import { ValidationError } from '../../errors';
|
|
4
|
+
import { InvalidNameError, Result } from '../../interface';
|
|
2
5
|
import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface';
|
|
3
6
|
|
|
4
7
|
/**
|
|
@@ -13,7 +16,7 @@ export class AlbumsCryptoService {
|
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
async createAlbum(
|
|
16
|
-
parentKeys: { key: PrivateKey; hashKey: Uint8Array },
|
|
19
|
+
parentKeys: { key: PrivateKey; hashKey: Uint8Array<ArrayBuffer> },
|
|
17
20
|
signingKeys: NodeSigningKeys,
|
|
18
21
|
name: string,
|
|
19
22
|
): Promise<{
|
|
@@ -62,7 +65,7 @@ export class AlbumsCryptoService {
|
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
async renameAlbum(
|
|
65
|
-
parentKeys: { key: PrivateKey; hashKey?: Uint8Array },
|
|
68
|
+
parentKeys: { key: PrivateKey; hashKey?: Uint8Array<ArrayBuffer> },
|
|
66
69
|
encryptedName: string,
|
|
67
70
|
signingKeys: NodeSigningKeys,
|
|
68
71
|
newName: string,
|
|
@@ -97,4 +100,52 @@ export class AlbumsCryptoService {
|
|
|
97
100
|
hash,
|
|
98
101
|
};
|
|
99
102
|
}
|
|
103
|
+
|
|
104
|
+
async encryptPhotoForAlbum(
|
|
105
|
+
nodeName: Result<string, Error | InvalidNameError>,
|
|
106
|
+
sha1: string,
|
|
107
|
+
nodeKeys: { passphrase: string; passphraseSessionKey: SessionKey; nameSessionKey: SessionKey },
|
|
108
|
+
albumKeys: { key: PrivateKey; hashKey: Uint8Array<ArrayBuffer> },
|
|
109
|
+
signingKeys: NodeSigningKeys,
|
|
110
|
+
): Promise<{
|
|
111
|
+
encryptedName: string;
|
|
112
|
+
hash: string;
|
|
113
|
+
contentHash: string;
|
|
114
|
+
armoredNodePassphrase: string;
|
|
115
|
+
armoredNodePassphraseSignature: string;
|
|
116
|
+
signatureEmail: string;
|
|
117
|
+
nameSignatureEmail: string;
|
|
118
|
+
}> {
|
|
119
|
+
if (!nodeName.ok) {
|
|
120
|
+
throw new ValidationError(c('Error').t`Cannot add photo to album without a valid name`);
|
|
121
|
+
}
|
|
122
|
+
if (signingKeys.type !== 'userAddress') {
|
|
123
|
+
throw new Error('Adding photos to album by anonymous user is not supported');
|
|
124
|
+
}
|
|
125
|
+
const email = signingKeys.email;
|
|
126
|
+
const signingKey = signingKeys.key;
|
|
127
|
+
|
|
128
|
+
const [{ armoredNodeName }, hash, contentHash, { armoredPassphrase, armoredPassphraseSignature }] =
|
|
129
|
+
await Promise.all([
|
|
130
|
+
this.driveCrypto.encryptNodeName(nodeName.value, nodeKeys.nameSessionKey, albumKeys.key, signingKey),
|
|
131
|
+
this.driveCrypto.generateLookupHash(nodeName.value, albumKeys.hashKey),
|
|
132
|
+
this.driveCrypto.generateLookupHash(sha1, albumKeys.hashKey),
|
|
133
|
+
this.driveCrypto.encryptPassphrase(
|
|
134
|
+
nodeKeys.passphrase,
|
|
135
|
+
nodeKeys.passphraseSessionKey,
|
|
136
|
+
[albumKeys.key],
|
|
137
|
+
signingKey,
|
|
138
|
+
),
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
encryptedName: armoredNodeName,
|
|
143
|
+
hash,
|
|
144
|
+
contentHash,
|
|
145
|
+
armoredNodePassphrase: armoredPassphrase,
|
|
146
|
+
armoredNodePassphraseSignature: armoredPassphraseSignature,
|
|
147
|
+
signatureEmail: email,
|
|
148
|
+
nameSignatureEmail: email,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
100
151
|
}
|