1 /* eslint-disable @typescript-eslint/no-throw-literal, curly */
2 import { all, fork, put, select } from 'redux-saga/effects';
4 import { getPublicKeysForEmail } from '@proton/pass/lib/auth/address';
5 import { PassCrypto } from '@proton/pass/lib/crypto';
6 import { type EventManagerEvent, NOOP_EVENT } from '@proton/pass/lib/events/manager';
7 import { decodeVaultContent } from '@proton/pass/lib/vaults/vault-proto.transformer';
8 import { syncInvites } from '@proton/pass/store/actions';
9 import type { InviteState } from '@proton/pass/store/reducers';
10 import { selectAllVaults } from '@proton/pass/store/selectors';
11 import { selectInvites } from '@proton/pass/store/selectors/invites';
12 import type { RootSagaOptions } from '@proton/pass/store/types';
13 import type { InvitesGetResponse, MaybeNull, Share, ShareType } from '@proton/pass/types';
14 import { type Api } from '@proton/pass/types';
15 import type { Invite } from '@proton/pass/types/data/invites';
16 import { prop } from '@proton/pass/utils/fp/lens';
17 import { truthy } from '@proton/pass/utils/fp/predicates';
18 import { logId, logger } from '@proton/pass/utils/logger';
19 import { toMap } from '@proton/shared/lib/helpers/object';
21 import { eventChannelFactory } from './channel.factory';
22 import { channelEventsWorker, channelInitWorker } from './channel.worker';
24 const NAMESPACE = 'ServerEvents::Invites';
26 function* onInvitesEvent(event: EventManagerEvent<InvitesGetResponse>) {
27 if ('error' in event) throw event.error;
29 const cachedInvites: InviteState = yield select(selectInvites);
30 const cachedInviteTokens = Object.keys(cachedInvites);
33 event.Invites.length === cachedInviteTokens.length &&
34 event.Invites.every(({ InviteToken }) => cachedInviteTokens.includes(InviteToken));
38 logger.info(`[ServerEvents::Invites] ${event.Invites.length} new invite(s) received`);
40 const vaults = (yield select(selectAllVaults)) as Share<ShareType.Vault>[];
41 const vaultIds = vaults.map(prop('vaultId'));
43 const invites: MaybeNull<Invite>[] = yield Promise.all(
44 event.Invites.map<Promise<MaybeNull<Invite>>>(async (invite) => {
45 /* Filter out invites that were just accepted. This is necessary
46 * because there might be a slight delay between invite acceptance
47 * and database replication, potentially causing accepted invites
48 * to still appear in the results. */
49 if (vaultIds.includes(invite.TargetID)) return null;
51 /* if invite already decrypted early return */
52 const cachedInvite = cachedInvites[invite.InviteToken];
53 if (cachedInvite) return cachedInvite;
55 /* FIXME: support item invites */
56 const encryptedVault = invite.VaultData;
57 if (!encryptedVault) return null;
59 const inviteKey = invite.Keys.find((key) => key.KeyRotation === encryptedVault.ContentKeyRotation);
60 if (!inviteKey) return null;
63 const encodedVault = await PassCrypto.readVaultInvite({
64 encryptedVaultContent: encryptedVault.Content,
65 invitedAddressId: invite.InvitedAddressID!,
67 inviterPublicKeys: await getPublicKeysForEmail(invite.InviterEmail),
71 createTime: invite.CreateTime,
72 invitedAddressId: invite.InvitedAddressID!,
73 invitedEmail: invite.InvitedEmail,
74 inviterEmail: invite.InviterEmail,
75 fromNewUser: invite.FromNewUser,
77 remindersSent: invite.RemindersSent,
78 targetId: invite.TargetID,
79 targetType: invite.TargetType,
80 token: invite.InviteToken,
82 content: decodeVaultContent(encodedVault),
83 memberCount: encryptedVault.MemberCount!,
84 itemCount: encryptedVault.ItemCount!,
87 } catch (err: unknown) {
88 logger.warn(`[${NAMESPACE}] Could not decrypt invite "${logId(invite.InviteToken)}"`, err);
94 yield put(syncInvites(toMap(invites.filter(truthy), 'token')));
97 export const createInvitesChannel = (api: Api) =>
98 eventChannelFactory<InvitesGetResponse>({
100 channelId: 'invites',
101 initialEventID: NOOP_EVENT,
102 getCursor: () => ({ EventID: NOOP_EVENT, More: false }),
103 query: () => ({ url: `pass/v1/invite`, method: 'get' }),
104 onEvent: onInvitesEvent,
105 onClose: () => logger.info(`[${NAMESPACE}] closing channel`),
108 export function* invitesChannel(api: Api, options: RootSagaOptions) {
109 logger.info(`[${NAMESPACE}] start polling`);
111 const eventsChannel = createInvitesChannel(api);
112 const events = fork(channelEventsWorker<InvitesGetResponse>, eventsChannel, options);
113 const wakeup = fork(channelInitWorker<InvitesGetResponse>, eventsChannel, options);
115 yield all([events, wakeup]);