Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / applications / drive / src / app / store / _links / useLinksActions.ts
blob4033986926c47dece954405f2e9038d98a19df40
1 import { usePreventLeave } from '@proton/components';
2 import { CryptoProxy } from '@proton/crypto';
3 import {
4     queryDeleteChildrenLinks,
5     queryDeleteTrashedLinks,
6     queryEmptyTrashOfShare,
7     queryRestoreLinks,
8     queryTrashLinks,
9 } from '@proton/shared/lib/api/drive/link';
10 import { queryMoveLink } from '@proton/shared/lib/api/drive/share';
11 import { queryVolumeEmptyTrash } from '@proton/shared/lib/api/drive/volume';
12 import { API_CODES } from '@proton/shared/lib/constants';
13 import { BATCH_REQUEST_SIZE, MAX_THREADS_PER_REQUEST } from '@proton/shared/lib/drive/constants';
14 import runInQueue from '@proton/shared/lib/helpers/runInQueue';
15 import { encryptPassphrase, generateLookupHash } from '@proton/shared/lib/keys/driveKeys';
16 import { getDecryptedSessionKey } from '@proton/shared/lib/keys/drivePassphrase';
17 import chunk from '@proton/utils/chunk';
18 import groupWith from '@proton/utils/groupWith';
20 import { EnrichedError } from '../../utils/errorHandling/EnrichedError';
21 import { ValidationError } from '../../utils/errorHandling/ValidationError';
22 import { useDebouncedRequest } from '../_api';
23 import { useDriveEventManager } from '../_events';
24 import { useDefaultShare, useShare } from '../_shares';
25 import { useVolumesState } from '../_volumes';
26 import useLink from './useLink';
27 import useLinks from './useLinks';
28 import useLinksState from './useLinksState';
30 const INVALID_REQUEST_ERROR_CODES = [
31     API_CODES.ALREADY_EXISTS_ERROR,
32     API_CODES.INVALID_REQUIREMENT_ERROR,
33     API_CODES.NOT_ALLOWED_ERROR,
36 interface APIResponses {
37     Responses: {
38         Response: {
39             Code: API_CODES;
40             Error?: string;
41         };
42     }[];
45 /**
46  * useLinksActions provides actions for manipulating with links in batches.
47  */
48 export function useLinksActions({
49     queries,
50 }: {
51     queries: {
52         queryDeleteChildrenLinks: typeof queryDeleteChildrenLinks;
53         queryDeleteTrashedLinks: typeof queryDeleteTrashedLinks;
54         queryEmptyTrashOfShare: typeof queryEmptyTrashOfShare;
55         queryRestoreLinks: typeof queryRestoreLinks;
56         queryTrashLinks: typeof queryTrashLinks;
57     };
58 }) {
59     const { preventLeave } = usePreventLeave();
60     const debouncedRequest = useDebouncedRequest();
61     const events = useDriveEventManager();
62     const { getLink, getLinkPassphraseAndSessionKey, getLinkPrivateKey, getLinkHashKey } = useLink();
63     const { getLinks } = useLinks();
64     const { lockLinks, lockTrash, unlockLinks } = useLinksState();
65     const { getDefaultShare } = useDefaultShare();
67     const { getShareCreatorKeys } = useShare();
68     const volumeState = useVolumesState();
70     /**
71      * withLinkLock is helper to lock provided `linkIds` before the action done
72      * using `callback`, and ensure links are unlocked after its done no matter
73      * the result of the action.
74      */
75     const withLinkLock = async <T>(shareId: string, linkIds: string[], callback: () => Promise<T>): Promise<T> => {
76         lockLinks(shareId, linkIds);
77         try {
78             return await callback();
79         } finally {
80             const volumeId = volumeState.findVolumeId(shareId);
81             if (volumeId) {
82                 await events.pollEvents.volumes(volumeId);
83             }
84             unlockLinks(shareId, linkIds);
85         }
86     };
88     const moveLink = async (
89         abortSignal: AbortSignal,
90         {
91             shareId,
92             newParentLinkId,
93             linkId,
94             newShareId = shareId,
95             silence = false,
96         }: {
97             shareId: string;
98             newParentLinkId: string;
99             linkId: string;
100             newShareId?: string;
101             silence?: boolean;
102         }
103     ) => {
104         const [
105             link,
106             { passphrase, passphraseSessionKey },
107             newParentPrivateKey,
108             newParentHashKey,
109             { privateKey: addressKey, address },
110         ] = await Promise.all([
111             getLink(abortSignal, shareId, linkId),
112             getLinkPassphraseAndSessionKey(abortSignal, shareId, linkId),
113             getLinkPrivateKey(abortSignal, newShareId, newParentLinkId),
114             getLinkHashKey(abortSignal, newShareId, newParentLinkId),
115             getShareCreatorKeys(abortSignal, newShareId),
116         ]);
118         if (link.corruptedLink) {
119             throw new Error('Cannot move corrupted file');
120         }
122         const [currentParentPrivateKey, Hash, ContentHash, { NodePassphrase }] = await Promise.all([
123             getLinkPrivateKey(abortSignal, shareId, link.parentLinkId),
124             generateLookupHash(link.name, newParentHashKey).catch((e) =>
125                 Promise.reject(
126                     new EnrichedError('Failed to generate lookup hash during move', {
127                         tags: {
128                             shareId,
129                             newParentLinkId,
130                             newShareId: newShareId === shareId ? undefined : newShareId,
131                             linkId,
132                         },
133                         extra: { e },
134                     })
135                 )
136             ),
137             link.digests?.sha1 &&
138                 generateLookupHash(link.digests.sha1, newParentHashKey).catch((e) =>
139                     Promise.reject(
140                         new EnrichedError('Failed to generate content hash during move', {
141                             tags: {
142                                 shareId,
143                                 newParentLinkId,
144                                 newShareId: newShareId === shareId ? undefined : newShareId,
145                                 linkId,
146                             },
147                             extra: { e },
148                         })
149                     )
150                 ),
151             encryptPassphrase(newParentPrivateKey, addressKey, passphrase, passphraseSessionKey).catch((e) =>
152                 Promise.reject(
153                     new EnrichedError('Failed to encrypt link passphrase during move', {
154                         tags: {
155                             shareId,
156                             newParentLinkId,
157                             newShareId: newShareId === shareId ? undefined : newShareId,
158                             linkId,
159                         },
160                         extra: { e },
161                     })
162                 )
163             ),
164         ]);
166         const sessionKeyName = await getDecryptedSessionKey({
167             data: link.encryptedName,
168             privateKeys: currentParentPrivateKey,
169         }).catch((e) =>
170             Promise.reject(
171                 new EnrichedError('Failed to decrypt link name session key during move', {
172                     tags: {
173                         shareId,
174                         newParentLinkId,
175                         newShareId: newShareId === shareId ? undefined : newShareId,
176                         linkId,
177                     },
178                     extra: { e },
179                 })
180             )
181         );
183         const { message: encryptedName } = await CryptoProxy.encryptMessage({
184             textData: link.name,
185             stripTrailingSpaces: true,
186             sessionKey: sessionKeyName,
187             encryptionKeys: newParentPrivateKey,
188             signingKeys: addressKey,
189         }).catch((e) =>
190             Promise.reject(
191                 new EnrichedError('Failed to encrypt link name during move', {
192                     tags: {
193                         shareId,
194                         newParentLinkId,
195                         newShareId: newShareId === shareId ? undefined : newShareId,
196                         linkId,
197                     },
198                     extra: { e },
199                 })
200             )
201         );
203         if (!link.signatureAddress) {
204             throw new Error('Moving anonymous file is not yet supported');
205         }
207         await debouncedRequest({
208             ...queryMoveLink(shareId, linkId, {
209                 Name: encryptedName,
210                 Hash,
211                 ParentLinkID: newParentLinkId,
212                 NodePassphrase,
213                 NameSignatureEmail: address.Email,
214                 NewShareID: newShareId === shareId ? undefined : newShareId,
215                 ContentHash,
216             }),
217             silence,
218         }).catch((err) => {
219             if (INVALID_REQUEST_ERROR_CODES.includes(err?.data?.Code)) {
220                 throw new ValidationError(err.data.Error);
221             }
222             throw err;
223         });
225         const originalParentId = link.parentLinkId;
226         return originalParentId;
227     };
229     const moveLinks = async (
230         abortSignal: AbortSignal,
231         {
232             shareId,
233             linkIds,
234             newParentLinkId,
235             newShareId,
236             onMoved,
237             onError,
238             silence,
239         }: {
240             shareId: string;
241             linkIds: string[];
242             newParentLinkId: string;
243             newShareId?: string;
244             onMoved?: (linkId: string) => void;
245             onError?: (linkId: string) => void;
246             silence?: boolean;
247         }
248     ) => {
249         return withLinkLock(shareId, linkIds, async () => {
250             const originalParentIds: { [linkId: string]: string } = {};
251             const successes: string[] = [];
252             const failures: { [linkId: string]: any } = {};
254             const moveQueue = linkIds.map((linkId) => async () => {
255                 return moveLink(abortSignal, { shareId, newParentLinkId, linkId, newShareId, silence })
256                     .then((originalParentId) => {
257                         successes.push(linkId);
258                         originalParentIds[linkId] = originalParentId;
259                         onMoved?.(linkId);
260                     })
261                     .catch((error) => {
262                         failures[linkId] = error;
263                         onError?.(linkId);
264                     });
265             });
267             await preventLeave(runInQueue(moveQueue, MAX_THREADS_PER_REQUEST));
268             return { successes, failures, originalParentIds };
269         });
270     };
272     /**
273      * batchHelper makes easier to do any action with many links in several
274      * batches to make sure API can handle it (to not send thousands of links
275      * in one request), all run in parallel (up to a reasonable limit).
276      */
277     const batchHelper = async <T>(
278         abortSignal: AbortSignal,
279         shareId: string,
280         linkIds: string[],
281         query: (batchLinkIds: string[], shareId: string) => any,
282         maxParallelRequests = MAX_THREADS_PER_REQUEST
283     ) => {
284         return withLinkLock(shareId, linkIds, async () => {
285             const responses: { batchLinkIds: string[]; response: T }[] = [];
286             const successes: string[] = [];
287             const failures: { [linkId: string]: any } = {};
289             const batches = chunk(linkIds, BATCH_REQUEST_SIZE);
291             const queue = batches.map(
292                 (batchLinkIds) => () =>
293                     debouncedRequest<T>(query(batchLinkIds, shareId), abortSignal)
294                         .then((response) => {
295                             responses.push({ batchLinkIds, response });
296                             batchLinkIds.forEach((linkId) => successes.push(linkId));
297                         })
298                         .catch((error) => {
299                             batchLinkIds.forEach((linkId) => (failures[linkId] = error));
300                         })
301             );
302             await preventLeave(runInQueue(queue, maxParallelRequests));
303             return {
304                 responses,
305                 successes,
306                 failures,
307             };
308         });
309     };
311     const batchHelperMultipleShares = async (
312         abortSignal: AbortSignal,
313         ids: { shareId: string; linkId: string }[],
314         query: (batchLinkIds: string[], shareId: string) => any,
315         maxParallelRequests = MAX_THREADS_PER_REQUEST
316     ) => {
317         const groupedByShareId = groupWith((a, b) => a.shareId === b.shareId, ids);
319         const results = await Promise.all(
320             groupedByShareId.map((group) => {
321                 return batchHelper<APIResponses>(
322                     abortSignal,
323                     group[0].shareId,
324                     group.map(({ linkId }) => linkId),
325                     query,
326                     maxParallelRequests
327                 );
328             })
329         );
331         const { responses, failures } = accumulateResults(results);
332         const successes: string[] = [];
333         responses.forEach(({ batchLinkIds, response }) => {
334             response.Responses.forEach(({ Response }, index) => {
335                 const linkId = batchLinkIds[index];
336                 if (!Response.Error) {
337                     successes.push(linkId);
338                 } else if (INVALID_REQUEST_ERROR_CODES.includes(Response.Code)) {
339                     failures[linkId] = new ValidationError(Response.Error);
340                 } else {
341                     failures[linkId] = Response.Error;
342                 }
343             });
344         });
345         return { responses, successes, failures };
346     };
348     const trashLinks = async (
349         abortSignal: AbortSignal,
350         ids: { shareId: string; linkId: string; parentLinkId: string }[]
351     ) => {
352         const linksByParentIds = groupWith((a, b) => a.parentLinkId === b.parentLinkId, ids);
354         const results = await Promise.all(
355             linksByParentIds.map((linksGroup) => {
356                 const groupParentLinkId = linksGroup[0].parentLinkId;
358                 return batchHelperMultipleShares(abortSignal, linksGroup, (batchLinkIds, shareId) => {
359                     return queries.queryTrashLinks(shareId, groupParentLinkId, batchLinkIds);
360                 });
361             })
362         );
364         return accumulateResults(results);
365     };
367     const restoreLinks = async (abortSignal: AbortSignal, ids: { shareId: string; linkId: string }[]) => {
368         /*
369             Make sure to restore the most freshly trashed links first to ensure
370             the potential parents are restored first because it is not possible
371             to restore child if the parent stays in the trash.
372             If user does not select the parent anyway, it is fine, it will just
373             show error notification that some link(s) were not restored.
374         */
375         const links = await getLinks(abortSignal, ids);
376         const sortedLinks = links.sort((a, b) => (b.trashed || 0) - (a.trashed || 0));
377         const sortedLinkIds = sortedLinks.map(({ linkId, rootShareId }) => ({ linkId, shareId: rootShareId }));
379         // Limit restore to one thread at a time only to make sure links are
380         // restored in proper order (parents need to be restored before childs).
381         const maxParallelRequests = 1;
383         const results = await batchHelperMultipleShares(
384             abortSignal,
385             sortedLinkIds,
386             (batchLinkIds, shareId) => {
387                 return queries.queryRestoreLinks(shareId, batchLinkIds);
388             },
389             maxParallelRequests
390         );
392         return results;
393     };
395     const deleteChildrenLinks = async (
396         abortSignal: AbortSignal,
397         shareId: string,
398         parentLinkId: string,
399         linkIds: string[]
400     ) => {
401         return batchHelper(abortSignal, shareId, linkIds, (batchLinkIds) =>
402             queryDeleteChildrenLinks(shareId, parentLinkId, batchLinkIds)
403         );
404     };
406     const deleteTrashedLinks = async (abortSignal: AbortSignal, ids: { linkId: string; shareId: string }[]) => {
407         return batchHelperMultipleShares(abortSignal, ids, (batchLinkIds, shareId) => {
408             return queries.queryDeleteTrashedLinks(shareId, batchLinkIds);
409         });
410     };
412     const emptyTrash = async (abortSignal: AbortSignal) => {
413         const { volumeId } = await getDefaultShare();
414         lockTrash();
415         await debouncedRequest(queryVolumeEmptyTrash(volumeId), abortSignal);
417         await events.pollEvents.volumes(volumeId);
418     };
420     return {
421         moveLinks,
422         trashLinks,
423         restoreLinks,
424         deleteChildrenLinks,
425         deleteTrashedLinks,
426         emptyTrash,
427     };
430 export default function useLinksActionsWithQuieries() {
431     return useLinksActions({
432         queries: {
433             queryTrashLinks,
434             queryDeleteChildrenLinks,
435             queryDeleteTrashedLinks,
436             queryEmptyTrashOfShare,
437             queryRestoreLinks,
438         },
439     });
442 interface Result<T> {
443     responses: {
444         batchLinkIds: string[];
445         response: T;
446     }[];
447     successes: string[];
448     failures: {
449         [linkId: string]: any;
450     };
453 function accumulateResults<T>(results: Result<T>[]): Result<T> {
454     return results.reduce(
455         (acc, result) => {
456             acc.responses.push(...result.responses);
457             acc.successes.push(...result.successes);
458             acc.failures = { ...acc.failures, ...result.failures };
459             return acc;
460         },
461         {
462             responses: [],
463             successes: [],
464             failures: {},
465         }
466     );