Use same lock values as mobile clients
[ProtonMail-WebClient.git] / packages / shared / lib / api / createApi.ts
blob830e81a6df415c5fce6fe1cc9229f17c1d929586
1 import { updateServerTime } from '@proton/crypto';
3 import configureApi from '../api';
4 import { getClientID } from '../apps/helper';
5 import { API_CUSTOM_ERROR_CODES } from '../errors';
6 import xhr from '../fetch/fetch';
7 import { withLocaleHeaders } from '../fetch/headers';
8 import { getDateHeader } from '../fetch/helpers';
9 import { localeCode } from '../i18n';
10 import type { Api } from '../interfaces';
11 import { getApiError, getApiErrorMessage, getIsOfflineError, getIsUnreachableError } from './helpers/apiErrorHelper';
12 import withApiHandlers from './helpers/withApiHandlers';
14 export const defaultApiStatus = {
15     offline: false,
16     apiUnreachable: '',
17     appVersionBad: false,
20 interface SilenceConfig {
21     silence?: boolean | number[];
24 const getSilenced = ({ silence }: SilenceConfig = {}, code: number) => {
25     if (Array.isArray(silence)) {
26         return silence.includes(code);
27     }
28     return !!silence;
31 type ServerTimeEvent = {
32     type: 'server-time';
33     payload: Date;
35 type ApiStatusEvent = {
36     type: 'status';
37     payload: Partial<typeof defaultApiStatus>;
39 type ApiNotificationEvent = {
40     type: 'notification';
41     payload: {
42         type: 'error';
43         text: string;
44         id?: number;
45         expiration?: number;
46     };
48 type ApiLogoutEvent = {
49     type: 'logout';
50     payload: {
51         error: any;
52     };
54 type ApiMissingScopeEvent = {
55     type: 'missing-scopes';
56     payload: {
57         scopes: string[];
58         error: any;
59         options: any;
60         resolve: (value: any) => void;
61         reject: (value: any) => void;
62     };
64 type ApiVerificationEvent = {
65     type: 'handle-verification';
66     payload: {
67         token: string;
68         methods: any[];
69         onVerify: (token: string, tokenType: string) => Promise<any>;
70         title: any;
71         error: any;
72         resolve: (value: any) => void;
73         reject: (value: any) => void;
74     };
76 type ApiEvent =
77     | ServerTimeEvent
78     | ApiStatusEvent
79     | ApiNotificationEvent
80     | ApiLogoutEvent
81     | ApiMissingScopeEvent
82     | ApiVerificationEvent;
83 export type ApiListenerCallback = (event: ApiEvent) => void;
85 export type ApiWithListener = Api & {
86     UID: string | undefined;
87     addEventListener: (cb: ApiListenerCallback) => void;
88     removeEventListener: (cb: ApiListenerCallback) => void;
91 const createApi = ({
92     config,
93     defaultHeaders,
94     noErrorState,
95     sendLocaleHeaders,
96 }: {
97     sendLocaleHeaders?: boolean;
98     defaultHeaders?: any;
99     config: any;
100     noErrorState?: boolean;
101 }): ApiWithListener => {
102     const call = configureApi({
103         ...config,
104         defaultHeaders,
105         clientID: getClientID(config.APP_NAME),
106         xhr,
107     }) as any;
109     const listeners: ApiListenerCallback[] = [];
110     const notify = (event: ApiEvent) => {
111         listeners.forEach((listener) => listener(event));
112     };
114     const handleMissingScopes = (data: any) => {
115         if (!listeners.length) {
116             return Promise.reject(data.error);
117         }
118         return new Promise((resolve, reject) => {
119             notify({
120                 type: 'missing-scopes',
121                 payload: {
122                     ...data,
123                     resolve,
124                     reject,
125                 },
126             });
127         });
128     };
130     const handleVerification = (data: any) => {
131         if (!listeners.length) {
132             return Promise.reject(data.error);
133         }
134         return new Promise((resolve, reject) => {
135             notify({
136                 type: 'handle-verification',
137                 payload: {
138                     ...data,
139                     resolve,
140                     reject,
141                 },
142             });
143         });
144     };
146     const callWithApiHandlers = withApiHandlers({
147         call,
148         onMissingScopes: handleMissingScopes,
149         onVerification: handleVerification,
150     }) as any;
152     const offlineSet = new Set<string>();
154     const callback: Api = ({ output = 'json', ...rest }: any) => {
155         // Only need to send locale headers in public app
156         const config = sendLocaleHeaders ? withLocaleHeaders(localeCode, rest) : rest;
157         return callWithApiHandlers(config)
158             .then((response: any) => {
159                 const serverTime = getDateHeader(response.headers);
160                 if (!serverTime) {
161                     // The HTTP Date header is mandatory, so this should never occur.
162                     // We need the server time for proper time sync:
163                     // falling back to the local time can result in e.g. unverifiable signatures
164                     throw new Error('Could not fetch server time');
165                 }
167                 notify({
168                     type: 'server-time',
169                     payload: updateServerTime(serverTime),
170                 });
171                 notify({
172                     type: 'status',
173                     payload: defaultApiStatus,
174                 });
175                 offlineSet.clear();
177                 if (output === 'stream') {
178                     return response.body;
179                 }
180                 if (output === 'raw') {
181                     return response;
182                 }
183                 return response[output]();
184             })
185             .catch((e: any) => {
186                 const serverTime = e.response?.headers ? getDateHeader(e.response.headers) : undefined;
187                 if (serverTime) {
188                     notify({
189                         type: 'server-time',
190                         payload: updateServerTime(serverTime),
191                     });
192                 }
194                 const { code } = getApiError(e);
195                 const errorMessage = getApiErrorMessage(e);
197                 const isSilenced = getSilenced(e.config, code);
199                 const handleErrorNotification = () => {
200                     if (!errorMessage || isSilenced) {
201                         return;
202                     }
203                     const codeExpirations = {
204                         [API_CUSTOM_ERROR_CODES.USER_RESTRICTED_STATE]: 10_000,
205                     };
206                     notify({
207                         type: 'notification',
208                         payload: {
209                             type: 'error',
210                             text: errorMessage,
211                             ...(code in codeExpirations
212                                 ? {
213                                       key: code,
214                                       expiration: codeExpirations[code],
215                                   }
216                                 : {
217                                       expiration: config?.notificationExpiration,
218                                   }),
219                         },
220                     });
221                 };
223                 // Intended for the verify app where we always want to pass an error notification
224                 if (noErrorState) {
225                     handleErrorNotification();
226                     throw e;
227                 }
229                 const isOffline = getIsOfflineError(e);
230                 const isUnreachable = getIsUnreachableError(e);
232                 if (isOffline) {
233                     offlineSet.add(e?.config?.url || '');
234                 } else {
235                     offlineSet.clear();
236                 }
238                 if (isOffline || isUnreachable) {
239                     notify({
240                         type: 'status',
241                         payload: {
242                             apiUnreachable: isUnreachable ? errorMessage || '' : '',
243                             // We wait to notify offline until at least 2 unique urls have been seen as offline
244                             offline: isOffline && offlineSet.size > 1,
245                         },
246                     });
247                     throw e;
248                 }
249                 notify({
250                     type: 'status',
251                     payload: {
252                         apiUnreachable: defaultApiStatus.apiUnreachable,
253                         offline: defaultApiStatus.offline,
254                     },
255                 });
257                 if (e.name === 'AbortError' || e.cancel) {
258                     throw e;
259                 }
261                 if (e.name === 'AppVersionBadError') {
262                     notify({ type: 'status', payload: { appVersionBad: true } });
263                     throw e;
264                 }
266                 if (e.name === 'InactiveSession') {
267                     notify({ type: 'logout', payload: { error: e } });
268                     throw e;
269                 }
271                 handleErrorNotification();
272                 throw e;
273             });
274     };
276     const getCallbackWithListeners = (callback: Api) => {
277         Object.defineProperties(callback, {
278             UID: {
279                 set(value: string | undefined) {
280                     callWithApiHandlers.UID = value;
281                     call.UID = value;
282                 },
283             },
284         });
286         return Object.assign(callback as ApiWithListener, {
287             addEventListener: (cb: ApiListenerCallback) => {
288                 listeners.push(cb);
289             },
290             removeEventListener: (cb: ApiListenerCallback) => {
291                 listeners.splice(listeners.indexOf(cb), 1);
292             },
293         });
294     };
296     return getCallbackWithListeners(callback);
299 export default createApi;