Merge branch 'feat/rbf-wording' into 'main'
[ProtonMail-WebClient.git] / packages / drive-store / store / _uploads / workerController.ts
blob7e2f709b39cc6c3a22ef844ac123d5f926b8c89f
1 import type { PrivateKeyReference, SessionKey } from '@proton/crypto';
2 import { CryptoProxy, serverTime, updateServerTime } from '@proton/crypto';
3 import type { SafeErrorObject } from '@proton/utils/getSafeErrorObject';
4 import { getSafeErrorObject } from '@proton/utils/getSafeErrorObject';
6 import { convertSafeError } from '../../utils/errorHandling/EnrichedError';
7 import { RefreshError, getRefreshError } from '../../utils/errorHandling/RefreshError';
8 import { HEARTBEAT_INTERVAL, HEARTBEAT_WAIT_TIME, WORKER_INIT_WAIT_TIME } from './constants';
9 import type {
10     EncryptedBlock,
11     FileKeys,
12     FileRequestBlock,
13     Link,
14     PhotoUpload,
15     ThumbnailEncryptedBlock,
16     ThumbnailRequestBlock,
17     VerificationData,
18 } from './interface';
19 import type { Media, ThumbnailInfo } from './media';
21 type GenerateKeysMessage = {
22     command: 'generate_keys';
23     addressPrivateKey: Uint8Array | undefined;
24     parentPrivateKey: Uint8Array;
25     serverTime: Date;
28 type StartMessage = {
29     command: 'start';
30     file: File;
31     mimeType: string;
32     isForPhotos: boolean;
33     thumbnails?: ThumbnailInfo[];
34     media?: Media;
35     addressPrivateKey: Uint8Array | undefined;
36     addressEmail: string;
37     privateKey: Uint8Array;
38     sessionKey: SessionKey;
39     parentHashKey: Uint8Array;
40     verificationData: VerificationData;
43 type CreatedBlocksMessage = {
44     command: 'created_blocks';
45     fileLinks: Link[];
46     thumbnailLinks?: Link[];
49 type PauseMessage = {
50     command: 'pause';
53 type ResumeMessage = {
54     command: 'resume';
57 type CloseMessage = {
58     command: 'close';
61 /**
62  * WorkerControllerEvent contains all possible events which can come from
63  * the main thread to the upload web worker.
64  */
65 type WorkerControllerEvent = {
66     data: GenerateKeysMessage | StartMessage | CreatedBlocksMessage | PauseMessage | ResumeMessage | CloseMessage;
69 /**
70  * WorkerHandlers defines what handlers are available to be used in the upload
71  * web worker to messages from the main thread defined in WorkerControllerEvent.
72  */
73 interface WorkerHandlers {
74     generateKeys: (addressPrivateKey: PrivateKeyReference | undefined, parentPrivateKey: PrivateKeyReference) => void;
75     start: (
76         file: File,
77         {
78             mimeType,
79             isForPhotos,
80             media,
81             thumbnails,
82         }: {
83             mimeType: string;
84             isForPhotos: boolean;
85             thumbnails?: ThumbnailInfo[];
86             media?: Media;
87         },
88         addressPrivateKey: PrivateKeyReference | undefined,
89         addressEmail: string,
90         privateKey: PrivateKeyReference,
91         sessionKey: SessionKey,
92         parentHashKey: Uint8Array,
93         verificationData: VerificationData
94     ) => void;
95     createdBlocks: (fileLinks: Link[], thumbnailLinks?: Link[]) => void;
96     pause: () => void;
97     resume: () => void;
100 type KeysGeneratedMessage = {
101     command: 'keys_generated';
102     nodeKey: string;
103     nodePassphrase: string;
104     nodePassphraseSignature: string;
105     contentKeyPacket: string;
106     contentKeyPacketSignature: string;
107     privateKey: Uint8Array;
108     sessionKey: SessionKey;
111 type CreateBlockMessage = {
112     command: 'create_blocks';
113     fileBlocks: FileRequestBlock[];
114     thumbnailBlocks?: ThumbnailRequestBlock[];
117 type ProgressMessage = {
118     command: 'progress';
119     increment: number;
122 type DoneMessage = {
123     command: 'done';
124     signature: string;
125     signatureAddress: string;
126     xattr: string;
127     photo?: PhotoUpload;
130 type NetworkErrorMessage = {
131     command: 'network_error';
132     error: SafeErrorObject;
135 type ErrorMessage = {
136     command: 'error';
137     error: SafeErrorObject;
140 type NotifyVerificationError = {
141     command: 'notify_verification_error';
142     retryHelped: boolean;
145 type HeartbeatMessage = {
146     command: 'heartbeat';
149 type WorkerAliveMessage = {
150     command: 'alive';
153 type LogMessage = {
154     command: 'log';
155     message: string;
159  * WorkerEvent contains all possible events which can come from the upload
160  * web worker to the main thread.
161  */
162 type WorkerEvent = {
163     data:
164         | KeysGeneratedMessage
165         | CreateBlockMessage
166         | ProgressMessage
167         | DoneMessage
168         | NetworkErrorMessage
169         | ErrorMessage
170         | NotifyVerificationError
171         | HeartbeatMessage
172         | WorkerAliveMessage
173         | LogMessage;
177  * WorkerControllerHandlers defines what handlers are available to be used
178  * in the main thread to messages from the upload web worked defined in
179  * WorkerEvent.
180  */
181 interface WorkerControllerHandlers {
182     keysGenerated: (keys: FileKeys) => void;
183     createBlocks: (fileBlocks: FileRequestBlock[], thumbnailBlocks?: ThumbnailRequestBlock[]) => void;
184     onProgress: (increment: number) => void;
185     finalize: (signature: string, signatureAddress: string, xattr: string, photo?: PhotoUpload) => void;
186     onNetworkError: (error: Error) => void;
187     onError: (error: Error) => void;
188     onHeartbeatTimeout: () => void;
189     onCancel: () => void;
190     notifySentry: (error: Error) => void;
191     notifyVerificationError: (retryHelped: boolean) => void;
195  * UploadWorker provides communication between the main thread and upload web
196  * worker. The class ensures type safety as much as possible.
197  * UploadWorker is meant to be used on the side of the web worker.
198  */
199 export class UploadWorker {
200     worker: Worker;
202     heartbeatInterval?: NodeJS.Timeout;
204     constructor(worker: Worker, { generateKeys, start, createdBlocks, pause, resume }: WorkerHandlers) {
205         // Before the worker termination, we want to release securely crypto
206         // proxy. That might need a bit of time, and we allow up to few seconds
207         // before we terminate the worker. During the releasing time, crypto
208         // might be failing, so any error should be ignored.
209         let closing = false;
211         this.worker = worker;
213         // Notify the main thread we are alive.
214         // We use this message to check for failures to load the worker.
215         this.postWorkerAlive();
217         // Set up the heartbeat. This notifies the main thread that the worker is still alive.
218         this.heartbeatInterval = setInterval(() => this.postHeartbeat(), HEARTBEAT_INTERVAL);
220         worker.addEventListener('message', ({ data }: WorkerControllerEvent) => {
221             switch (data.command) {
222                 case 'generate_keys':
223                     (async (data) => {
224                         let module;
225                         // Dynamic import is needed since we want pmcrypto (incl. openpgpjs) to be loaded
226                         // inside the worker, not in the main thread.
227                         try {
228                             module = await import(
229                                 /* webpackChunkName: "crypto-worker-api" */ '@proton/crypto/lib/worker/api'
230                             );
231                         } catch (e: any) {
232                             console.warn(e);
233                             this.postError(new RefreshError());
234                             return;
235                         }
237                         const { Api: CryptoApi } = module;
239                         CryptoApi.init();
240                         CryptoProxy.setEndpoint(new CryptoApi(), (endpoint) => endpoint.clearKeyStore());
242                         // align serverTime in worker with the main thread (received from API)
243                         updateServerTime(data.serverTime);
244                         const addressPrivateKey = data.addressPrivateKey
245                             ? await CryptoProxy.importPrivateKey({
246                                   binaryKey: data.addressPrivateKey,
247                                   passphrase: null,
248                               })
249                             : undefined;
251                         const parentPrivateKey = await CryptoProxy.importPrivateKey({
252                             binaryKey: data.parentPrivateKey,
253                             passphrase: null,
254                         });
255                         generateKeys(addressPrivateKey, parentPrivateKey);
256                     })(data).catch((err) => {
257                         this.postError(err);
258                     });
259                     break;
260                 case 'start':
261                     (async (data) => {
262                         const addressPrivateKey = data.addressPrivateKey
263                             ? await CryptoProxy.importPrivateKey({
264                                   binaryKey: data.addressPrivateKey,
265                                   passphrase: null,
266                               })
267                             : undefined;
268                         const privateKey = await CryptoProxy.importPrivateKey({
269                             binaryKey: data.privateKey,
270                             passphrase: null,
271                         });
272                         start(
273                             data.file,
274                             {
275                                 mimeType: data.mimeType,
276                                 isForPhotos: data.isForPhotos,
277                                 thumbnails: data.thumbnails,
278                                 media: data.media,
279                             },
280                             addressPrivateKey,
281                             data.addressEmail,
282                             privateKey,
283                             data.sessionKey,
284                             data.parentHashKey,
285                             data.verificationData
286                         );
287                     })(data).catch((err) => {
288                         this.postError(err);
289                     });
290                     break;
291                 case 'created_blocks':
292                     createdBlocks(data.fileLinks, data.thumbnailLinks);
293                     break;
294                 case 'pause':
295                     pause();
296                     break;
297                 case 'resume':
298                     resume();
299                     break;
300                 case 'close':
301                     closing = true;
302                     this.clearHeartbeatInterval();
303                     void CryptoProxy.releaseEndpoint().then(() => self.close());
304                     break;
305                 default:
306                     // Type linters should prevent this error.
307                     throw new Error('Unexpected message');
308             }
309         });
310         worker.addEventListener('error', (event: ErrorEvent) => {
311             if (closing) {
312                 return;
313             }
315             this.postError(event.error || new Error(event.message));
316         });
317         // @ts-ignore
318         worker.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
319             event.preventDefault();
320             if (closing) {
321                 return;
322             }
324             let error = event.reason;
326             if (typeof error === 'string') {
327                 error = new Error(error);
328             }
330             this.postError(error);
331         });
332     }
334     clearHeartbeatInterval() {
335         if (this.heartbeatInterval) {
336             clearInterval(this.heartbeatInterval);
337         }
338     }
340     async postKeysGenerated(keys: FileKeys) {
341         this.worker.postMessage({
342             command: 'keys_generated',
343             ...keys,
344             privateKey: await CryptoProxy.exportPrivateKey({
345                 privateKey: keys.privateKey,
346                 passphrase: null,
347                 format: 'binary',
348             }),
349         } satisfies KeysGeneratedMessage);
350     }
352     postCreateBlocks(fileBlocks: EncryptedBlock[], encryptedThumbnailBlocks?: ThumbnailEncryptedBlock[]) {
353         this.worker.postMessage({
354             command: 'create_blocks',
355             fileBlocks: fileBlocks.map<FileRequestBlock>((block) => ({
356                 index: block.index,
357                 signature: block.signature,
358                 size: block.encryptedData.byteLength,
359                 hash: block.hash,
360                 verificationToken: block.verificationToken,
361             })),
362             thumbnailBlocks: encryptedThumbnailBlocks?.map((thumbnailBlock) => ({
363                 index: thumbnailBlock.index,
364                 size: thumbnailBlock.encryptedData.byteLength,
365                 hash: thumbnailBlock.hash,
366                 type: thumbnailBlock.thumbnailType,
367             })),
368         } satisfies CreateBlockMessage);
369     }
371     postProgress(increment: number) {
372         this.worker.postMessage({
373             command: 'progress',
374             increment,
375         } satisfies ProgressMessage);
376     }
378     postDone(signature: string, signatureAddress: string, xattr: string, photo?: PhotoUpload) {
379         this.worker.postMessage({
380             command: 'done',
381             signature,
382             signatureAddress,
383             xattr,
384             photo,
385         } satisfies DoneMessage);
386     }
388     postNetworkError(error: Error) {
389         this.worker.postMessage({
390             command: 'network_error',
391             error: getSafeErrorObject(error),
392         } satisfies NetworkErrorMessage);
393     }
395     postError(error: Error) {
396         if (!error) {
397             error = new Error('Unknown error');
398         }
400         this.worker.postMessage({
401             command: 'error',
402             error: getSafeErrorObject(error),
403         } satisfies ErrorMessage);
404     }
406     notifyVerificationError(retryHelped: boolean) {
407         this.worker.postMessage({
408             command: 'notify_verification_error',
409             retryHelped,
410         } satisfies NotifyVerificationError);
411     }
413     postHeartbeat() {
414         this.worker.postMessage({
415             command: 'heartbeat',
416         } satisfies HeartbeatMessage);
417     }
419     postWorkerAlive() {
420         this.worker.postMessage({
421             command: 'alive',
422         } satisfies WorkerAliveMessage);
423     }
425     postLog(message: string) {
426         this.worker.postMessage({
427             command: 'log',
428             message,
429         } satisfies LogMessage);
430     }
434  * UploadWorkerController provides communication between the main thread and
435  * upload web worker. The class ensures type safety as much as possible.
436  * UploadWorkerController is meant to be used on the side of the main thread.
437  */
438 export class UploadWorkerController {
439     worker: Worker;
441     onCancel: () => void;
443     heartbeatTimeout?: NodeJS.Timeout;
445     /**
446      * On Chrome, there is no way to know if a worker fails to load.
447      * If the worker is not loaded, it simply won't respond to any messages at all.
448      *
449      * The heartbeat could handle this, but since it takes 30 seconds,
450      * it's not particularly great UX. So instead we run a localized timeout, with a
451      * quicker turn-around time in case of failure.
452      */
453     workerTimeout?: NodeJS.Timeout;
455     constructor(
456         worker: Worker,
457         log: (message: string) => void,
458         {
459             keysGenerated,
460             createBlocks,
461             onProgress,
462             finalize,
463             onNetworkError,
464             onError,
465             onCancel,
466             notifySentry,
467             notifyVerificationError,
468             onHeartbeatTimeout,
469         }: WorkerControllerHandlers
470     ) {
471         this.worker = worker;
472         this.onCancel = onCancel;
474         this.workerTimeout = setTimeout(() => {
475             worker?.terminate();
476             onError(getRefreshError());
477         }, WORKER_INIT_WAIT_TIME);
479         worker.addEventListener('message', ({ data }: WorkerEvent) => {
480             switch (data.command) {
481                 case 'alive':
482                     log('Worker alive');
483                     clearTimeout(this.workerTimeout);
484                     break;
485                 case 'keys_generated':
486                     log('File keys generated');
487                     (async (data) => {
488                         const privateKey = await CryptoProxy.importPrivateKey({
489                             binaryKey: data.privateKey,
490                             passphrase: null,
491                         });
492                         keysGenerated({
493                             nodeKey: data.nodeKey,
494                             nodePassphrase: data.nodePassphrase,
495                             nodePassphraseSignature: data.nodePassphraseSignature,
496                             contentKeyPacket: data.contentKeyPacket,
497                             contentKeyPacketSignature: data.contentKeyPacketSignature,
498                             privateKey,
499                             sessionKey: data.sessionKey,
500                         });
501                     })(data).catch((err) => {
502                         this.clearHeartbeatTimeout();
503                         onError(err);
504                     });
505                     break;
506                 case 'create_blocks':
507                     createBlocks(data.fileBlocks, data.thumbnailBlocks);
508                     break;
509                 case 'progress':
510                     onProgress(data.increment);
511                     break;
512                 case 'done':
513                     this.clearHeartbeatTimeout();
514                     finalize(data.signature, data.signatureAddress, data.xattr, data.photo);
515                     break;
516                 case 'network_error':
517                     onNetworkError(data.error);
518                     break;
519                 case 'error':
520                     this.clearHeartbeatTimeout();
522                     if (data.error.name === 'RefreshError') {
523                         onError(getRefreshError());
524                         break;
525                     }
527                     onError(convertSafeError(data.error));
528                     break;
529                 case 'notify_verification_error':
530                     notifyVerificationError(data.retryHelped);
531                     break;
532                 case 'heartbeat':
533                     log('Got heartbeat');
534                     this.clearHeartbeatTimeout();
536                     this.heartbeatTimeout = setTimeout(() => {
537                         log('Heartbeat timeout');
539                         notifySentry(new Error('Heartbeat was not received in time'));
541                         onHeartbeatTimeout();
543                         // Since the worker is stuck, we can terminate it
544                         this.worker.terminate();
545                     }, HEARTBEAT_WAIT_TIME);
547                     break;
548                 case 'log':
549                     log(data.message);
550                     break;
551                 default:
552                     // Type linters should prevent this error.
553                     throw new Error('Unexpected message');
554             }
555         });
556         worker.addEventListener('error', (event: ErrorEvent) => {
557             // When a worker fails to load (i.e. 404), an error event is sent without ErrorEvent properties
558             // This isn't properly documented, but basically, some browsers (Firefox) seem to handle
559             // failure to load the worker URL in this way, so this is why we have this condition.
560             // https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent
561             if (!('filename' in event)) {
562                 onError(getRefreshError());
563                 return;
564             }
566             onError(event.error || new Error(event.message));
567         });
568     }
570     clearHeartbeatTimeout() {
571         if (this.heartbeatTimeout) {
572             clearTimeout(this.heartbeatTimeout);
573         }
574     }
576     terminate() {
577         this.clearHeartbeatTimeout();
578         this.worker.terminate();
579     }
581     cancel() {
582         this.onCancel();
583     }
585     async postGenerateKeys(addressPrivateKey: PrivateKeyReference | undefined, parentPrivateKey: PrivateKeyReference) {
586         this.worker.postMessage({
587             command: 'generate_keys',
588             addressPrivateKey: addressPrivateKey
589                 ? await CryptoProxy.exportPrivateKey({
590                       privateKey: addressPrivateKey,
591                       passphrase: null,
592                       format: 'binary',
593                   })
594                 : undefined,
595             parentPrivateKey: await CryptoProxy.exportPrivateKey({
596                 privateKey: parentPrivateKey,
597                 passphrase: null,
598                 format: 'binary',
599             }),
600             serverTime: serverTime(),
601         } satisfies GenerateKeysMessage);
602     }
604     async postStart(
605         file: File,
606         {
607             mimeType,
608             isForPhotos,
609             thumbnails,
610             media,
611         }: {
612             mimeType: string;
613             isForPhotos: boolean;
614             thumbnails?: ThumbnailInfo[];
615             media?: Media;
616         },
617         addressPrivateKey: PrivateKeyReference | undefined,
618         addressEmail: string,
619         privateKey: PrivateKeyReference,
620         sessionKey: SessionKey,
621         parentHashKey: Uint8Array,
622         verificationData: VerificationData
623     ) {
624         this.worker.postMessage({
625             command: 'start',
626             file,
627             mimeType,
628             isForPhotos: isForPhotos,
629             thumbnails,
630             media,
631             addressPrivateKey: addressPrivateKey
632                 ? await CryptoProxy.exportPrivateKey({
633                       privateKey: addressPrivateKey,
634                       passphrase: null,
635                       format: 'binary',
636                   })
637                 : undefined,
638             addressEmail,
639             privateKey: await CryptoProxy.exportPrivateKey({
640                 privateKey: privateKey,
641                 passphrase: null,
642                 format: 'binary',
643             }),
644             sessionKey,
645             parentHashKey,
646             verificationData,
647         } satisfies StartMessage);
648     }
650     postCreatedBlocks(fileLinks: Link[], thumbnailLinks?: Link[]) {
651         this.worker.postMessage({
652             command: 'created_blocks',
653             fileLinks,
654             thumbnailLinks,
655         } satisfies CreatedBlocksMessage);
656     }
658     postPause() {
659         this.worker.postMessage({
660             command: 'pause',
661         } satisfies PauseMessage);
662     }
664     postResume() {
665         this.worker.postMessage({
666             command: 'resume',
667         } satisfies ResumeMessage);
668     }
670     postClose() {
671         this.worker.postMessage({
672             command: 'close',
673         } satisfies CloseMessage);
674     }