Merge branch 'IDTEAM-1.26.0' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / calendar / export / export.ts
blob5926c552ee3cf4917899085102686221a107fd11
1 import { fromUnixTime } from 'date-fns';
2 import { c } from 'ttag';
4 import { CryptoProxy } from '@proton/crypto';
5 import { withSupportedSequence } from '@proton/shared/lib/calendar/icsSurgery/vevent';
6 import isTruthy from '@proton/utils/isTruthy';
7 import partition from '@proton/utils/partition';
8 import unique from '@proton/utils/unique';
10 import { getEvent, queryEventsIDs } from '../../api/calendars';
11 import { getSilentApi } from '../../api/helpers/customConfig';
12 import { SECOND } from '../../constants';
13 import formatUTC from '../../date-fns-utc/format';
14 import type { WeekStartsOn } from '../../date-fns-utc/interface';
15 import {
16     formatGMTOffsetAbbreviation,
17     fromUTCDate,
18     fromUTCDateToLocalFakeUTCDate,
19     getTimezoneOffset,
20 } from '../../date/timezone';
21 import { dateLocale } from '../../i18n';
22 import type { Address, Api, Key } from '../../interfaces';
23 import type {
24     CalendarEvent,
25     CalendarSettings,
26     ExportError,
27     VcalVeventComponent,
28     VisualCalendar,
29 } from '../../interfaces/calendar';
30 import { EXPORT_EVENT_ERROR_TYPES } from '../../interfaces/calendar';
31 import type { CalendarEventsIDsQuery } from '../../interfaces/calendar/Api';
32 import type { GetAddressKeys } from '../../interfaces/hooks/GetAddressKeys';
33 import type { GetCalendarKeys } from '../../interfaces/hooks/GetCalendarKeys';
34 import { getIsAutoAddedInvite } from '../apiModels';
35 import { withNormalizedAuthors } from '../author';
36 import { getIsOwnedCalendar } from '../calendar';
37 import { getCalendarEventDecryptionKeys } from '../crypto/keys/helpers';
38 import { readCalendarEvent, readSessionKeys } from '../deserialize';
39 import { getTimezonedFrequencyString } from '../recurrence/getFrequencyString';
40 import { fromRruleString } from '../vcal';
41 import { getDateProperty } from '../vcalConverter';
42 import { withMandatoryPublishFields } from '../veventHelper';
44 export const getHasCalendarEventMatchingSigningKeys = async (event: CalendarEvent, keys: Key[]) => {
45     const allEventSignatures = [...event.SharedEvents, ...event.CalendarEvents, ...event.AttendeesEvents].flatMap(
46         (event) => (event.Signature ? [event.Signature] : [])
47     );
49     const allSignaturesKeyInfo = await Promise.all(
50         allEventSignatures.map((armoredSignature) => CryptoProxy.getSignatureInfo({ armoredSignature }))
51     );
52     const allSigningKeyIDs = unique(allSignaturesKeyInfo.flatMap(({ signingKeyIDs }) => signingKeyIDs));
53     for (const { PrivateKey: armoredKey } of keys) {
54         const { keyIDs } = await CryptoProxy.getKeyInfo({ armoredKey });
55         const isSigningKey = keyIDs.some((keyID) => allSigningKeyIDs.includes(keyID));
57         if (isSigningKey) {
58             return true;
59         }
60     }
62     return false;
65 export interface GetErrorProps {
66     event: CalendarEvent;
67     errorType: EXPORT_EVENT_ERROR_TYPES;
68     weekStartsOn: WeekStartsOn;
69     defaultTzid: string;
72 export const getError = ({ event, errorType, weekStartsOn, defaultTzid }: GetErrorProps): ExportError => {
73     const { StartTime, RRule, FullDay } = event;
74     const startDate = new Date(StartTime * SECOND);
75     const fakeUTCStartDate = fromUTCDateToLocalFakeUTCDate(startDate, !!FullDay, defaultTzid);
76     const startDateString = formatUTC(fakeUTCStartDate, FullDay ? 'P' : 'Pp', { locale: dateLocale });
77     const { offset } = getTimezoneOffset(startDate, defaultTzid);
78     const offsetString = formatGMTOffsetAbbreviation(offset);
79     const timeString = `${startDateString}${FullDay ? '' : ` ${offsetString}`}`;
81     const rruleValueFromString = RRule ? fromRruleString(RRule) : undefined;
82     const utcStartDate = fromUnixTime(StartTime);
83     const dtstart = getDateProperty(fromUTCDate(utcStartDate));
85     if (rruleValueFromString) {
86         const rruleString = getTimezonedFrequencyString({ value: rruleValueFromString }, dtstart, {
87             currentTzid: defaultTzid,
88             locale: dateLocale,
89             weekStartsOn,
90         });
92         return [c('Error when exporting event from calendar').t`Event from ${timeString}, ${rruleString}`, errorType];
93     }
95     return [c('Error when exporting event from calendar').t`Event @ ${timeString}`, errorType];
98 const getDecryptionErrorType = async (event: CalendarEvent, keys: Key[]) => {
99     try {
100         const HasMatchingKeys = await getHasCalendarEventMatchingSigningKeys(event, keys);
101         if (HasMatchingKeys) {
102             return EXPORT_EVENT_ERROR_TYPES.PASSWORD_RESET;
103         }
104         return EXPORT_EVENT_ERROR_TYPES.DECRYPTION_ERROR;
105     } catch {
106         return EXPORT_EVENT_ERROR_TYPES.DECRYPTION_ERROR;
107     }
110 const decryptEvent = async ({
111     event,
112     calendarEmail,
113     calendarSettings,
114     defaultTzid,
115     weekStartsOn,
116     addresses,
117     getAddressKeys,
118     getCalendarKeys,
119 }: {
120     event: CalendarEvent;
121     calendarEmail: string;
122     calendarSettings: CalendarSettings;
123     addresses: Address[];
124     getAddressKeys: GetAddressKeys;
125     getCalendarKeys: GetCalendarKeys;
126     weekStartsOn: WeekStartsOn;
127     defaultTzid: string;
128 }) => {
129     const defaultParams = { event, defaultTzid, weekStartsOn };
130     const eventDecryptionKeys = await getCalendarEventDecryptionKeys({
131         calendarEvent: event,
132         getAddressKeys,
133         getCalendarKeys,
134     });
136     try {
137         const [sharedSessionKey, calendarSessionKey] = await readSessionKeys({
138             calendarEvent: event,
139             privateKeys: eventDecryptionKeys,
140         });
142         const { CalendarID, ID, SharedEvents, CalendarEvents, AttendeesEvents, Attendees, Notifications, FullDay } =
143             event;
145         const { veventComponent } = await readCalendarEvent({
146             event: {
147                 SharedEvents: withNormalizedAuthors(SharedEvents),
148                 CalendarEvents: withNormalizedAuthors(CalendarEvents),
149                 AttendeesEvents: withNormalizedAuthors(AttendeesEvents),
150                 Attendees,
151                 Notifications,
152                 FullDay,
153                 CalendarID,
154                 ID,
155                 // do not export color
156                 Color: null,
157             },
158             calendarSettings,
159             sharedSessionKey,
160             calendarSessionKey,
161             addresses,
162             encryptingAddressID: getIsAutoAddedInvite(event) ? event.AddressID : undefined,
163         });
165         return withSupportedSequence(withMandatoryPublishFields(veventComponent, calendarEmail));
166     } catch (error: any) {
167         const inactiveKeys = addresses.flatMap(({ Keys }) => Keys.filter(({ Active }) => !Active));
168         return getError({
169             ...defaultParams,
170             errorType: await getDecryptionErrorType(event, inactiveKeys),
171         });
172     }
175 const tryDecryptEvent = async ({
176     calendar,
177     event,
178     calendarSettings,
179     defaultTzid,
180     weekStartsOn,
181     addresses,
182     getAddressKeys,
183     getCalendarKeys,
184 }: {
185     calendar: VisualCalendar;
186     event: CalendarEvent;
187     calendarSettings: CalendarSettings;
188     addresses: Address[];
189     getAddressKeys: GetAddressKeys;
190     getCalendarKeys: GetCalendarKeys;
191     weekStartsOn: WeekStartsOn;
192     defaultTzid: string;
193 }) => {
194     // ignore auto-added invites in shared calendars (they can't be decrypted and we don't display them in the UI)
195     if (!getIsOwnedCalendar(calendar) && getIsAutoAddedInvite(event)) {
196         return null;
197     }
198     return decryptEvent({
199         event,
200         calendarEmail: calendar.Email,
201         calendarSettings,
202         defaultTzid,
203         weekStartsOn,
204         addresses,
205         getAddressKeys,
206         getCalendarKeys,
207     });
210 const fetchAndTryDecryptEvent = async ({
211     api,
212     eventID,
213     calendar,
214     calendarSettings,
215     defaultTzid,
216     weekStartsOn,
217     addresses,
218     getAddressKeys,
219     getCalendarKeys,
220 }: {
221     api: Api;
222     eventID: string;
223     calendar: VisualCalendar;
224     calendarSettings: CalendarSettings;
225     addresses: Address[];
226     getAddressKeys: GetAddressKeys;
227     getCalendarKeys: GetCalendarKeys;
228     weekStartsOn: WeekStartsOn;
229     defaultTzid: string;
230 }) => {
231     const { Event: event } = await getSilentApi(api)<{ Event: CalendarEvent }>(getEvent(calendar.ID, eventID));
232     return tryDecryptEvent({
233         event,
234         calendar,
235         calendarSettings,
236         defaultTzid,
237         weekStartsOn,
238         addresses,
239         getAddressKeys,
240         getCalendarKeys,
241     });
244 interface ProcessData {
245     calendar: VisualCalendar;
246     addresses: Address[];
247     getAddressKeys: GetAddressKeys;
248     getCalendarKeys: GetCalendarKeys;
249     api: Api;
250     signal: AbortSignal;
251     onProgress: (
252         calendarEventIDs: string[],
253         veventComponents: VcalVeventComponent[],
254         exportErrors: ExportError[]
255     ) => void;
256     totalToProcess: number;
257     calendarSettings: CalendarSettings;
258     weekStartsOn: WeekStartsOn;
259     defaultTzid: string;
262 export const processInBatches = async ({
263     calendar,
264     api,
265     signal,
266     onProgress,
267     addresses,
268     totalToProcess,
269     getAddressKeys,
270     getCalendarKeys,
271     calendarSettings,
272     weekStartsOn,
273     defaultTzid,
274 }: ProcessData): Promise<[VcalVeventComponent[], ExportError[], number]> => {
275     const PAGE_SIZE = 100;
276     const batchesLength = Math.ceil(totalToProcess / PAGE_SIZE);
277     const processed: VcalVeventComponent[] = [];
278     const errors: ExportError[] = [];
279     const promises: Promise<void>[] = [];
280     let totalEventsFetched = 0;
282     let lastId;
284     for (let i = 0; i < batchesLength; i++) {
285         if (signal.aborted) {
286             return [[], [], totalToProcess];
287         }
289         const params: CalendarEventsIDsQuery = {
290             Limit: PAGE_SIZE,
291             AfterID: lastId,
292         };
294         const IDs = (await api<{ IDs: string[] }>(queryEventsIDs(calendar.ID, params))).IDs;
296         if (signal.aborted) {
297             return [[], [], totalToProcess];
298         }
300         onProgress(IDs, [], []);
302         lastId = IDs[IDs.length - 1];
303         totalEventsFetched += IDs.length;
305         const promise = Promise.all(
306             IDs.map((eventID) =>
307                 fetchAndTryDecryptEvent({
308                     api,
309                     eventID,
310                     calendar,
311                     calendarSettings,
312                     defaultTzid,
313                     weekStartsOn,
314                     addresses,
315                     getAddressKeys,
316                     getCalendarKeys,
317                 })
318             )
319         )
320             .then((veventsOrErrors) => {
321                 const veventsOrErrorsNonNull: (VcalVeventComponent | ExportError)[] = veventsOrErrors.filter(isTruthy);
322                 const [veventComponents, exportErrors] = partition<VcalVeventComponent, ExportError>(
323                     veventsOrErrorsNonNull,
324                     (item): item is VcalVeventComponent => !Array.isArray(item)
325                 );
327                 processed.push(...veventComponents);
328                 errors.push(...exportErrors);
329                 onProgress([], veventComponents, exportErrors);
330             })
331             .catch((e) => {
332                 const exportErrors: ExportError[] = IDs.map(() => [
333                     e.message,
334                     EXPORT_EVENT_ERROR_TYPES.DECRYPTION_ERROR,
335                 ]);
336                 errors.push(...exportErrors);
337                 onProgress([], [], exportErrors);
338             });
340         promises.push(promise);
341     }
343     await Promise.all(promises);
345     return [processed, errors, totalEventsFetched];