diff --git a/keyserver/src/push/rescind.js b/keyserver/src/push/rescind.js index 10afe9358..3bc9c0596 100644 --- a/keyserver/src/push/rescind.js +++ b/keyserver/src/push/rescind.js @@ -1,384 +1,385 @@ // @flow import apn from '@parse/node-apn'; import type { ResponseFailure } from '@parse/node-apn'; import type { FirebaseError } from 'firebase-admin'; import invariant from 'invariant'; import { createAndroidNotificationRescind } from 'lib/push/android-notif-creators.js'; import { getAPNsNotificationTopic } from 'lib/shared/notif-utils.js'; import type { PlatformDetails } from 'lib/types/device-types.js'; import type { NotificationTargetDevice, TargetedAndroidNotification, SenderDeviceDescriptor, EncryptedNotifUtilsAPI, } from 'lib/types/notif-types.js'; import { threadSubscriptions } from 'lib/types/subscription-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { promiseAll } from 'lib/utils/promises.js'; import { tID } from 'lib/utils/validation-utils.js'; import { prepareEncryptedIOSNotificationRescind } from './crypto.js'; import encryptedNotifUtilsAPI from './encrypted-notif-utils-api.js'; import type { TargetedAPNsNotification } from './types.js'; import { apnPush, fcmPush, type APNPushResult, type FCMPushResult, } from './utils.js'; import createIDs from '../creators/id-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import { thisKeyserverID } from '../user/identity.js'; import { validateOutput } from '../utils/validation-utils.js'; type ParsedDelivery = { +platform: 'ios' | 'macos' | 'android', +codeVersion: ?number, +stateVersion: ?number, +notificationID: string, +deviceTokens: $ReadOnlyArray, }; type RescindDelivery = { source: 'rescind', rescindedID: string, errors?: | $ReadOnlyArray | $ReadOnlyArray, }; async function rescindPushNotifs( notifCondition: SQLStatementType, inputCountCondition?: SQLStatementType, ) { const notificationExtractString = `$.${threadSubscriptions.home}`; const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; const fetchQuery = SQL` SELECT n.id, n.user, n.thread, n.message, n.delivery, n.collapse_key, COUNT( `; fetchQuery.append(inputCountCondition ? inputCountCondition : SQL`m.thread`); fetchQuery.append(SQL` ) AS unread_count FROM notifications n LEFT JOIN memberships m ON m.user = n.user AND m.last_message > m.last_read_message AND m.role > 0 AND JSON_EXTRACT(subscription, ${notificationExtractString}) AND JSON_EXTRACT(permissions, ${visPermissionExtractString}) WHERE n.rescinded = 0 AND `); fetchQuery.append(notifCondition); fetchQuery.append(SQL` GROUP BY n.id, m.user`); const [[fetchResult], keyserverID] = await Promise.all([ dbQuery(fetchQuery), thisKeyserverID(), ]); const allDeviceTokens = new Set(); const parsedDeliveries: { [string]: $ReadOnlyArray } = {}; for (const row of fetchResult) { const rawDelivery = JSON.parse(row.delivery); const deliveries = Array.isArray(rawDelivery) ? rawDelivery : [rawDelivery]; const id = row.id.toString(); const rowParsedDeliveries = []; for (const delivery of deliveries) { if ( delivery.iosID || delivery.deviceType === 'ios' || delivery.deviceType === 'macos' ) { const deviceTokens = delivery.iosDeviceTokens ?? delivery.deviceTokens; rowParsedDeliveries.push({ notificationID: delivery.iosID, codeVersion: delivery.codeVersion, stateVersion: delivery.stateVersion, platform: delivery.deviceType ?? 'ios', deviceTokens, }); deviceTokens.forEach(deviceToken => allDeviceTokens.add(deviceToken)); } else if (delivery.androidID || delivery.deviceType === 'android') { const deviceTokens = delivery.androidDeviceTokens ?? delivery.deviceTokens; rowParsedDeliveries.push({ notificationID: row.collapse_key ? row.collapse_key : id, codeVersion: delivery.codeVersion, stateVersion: delivery.stateVersion, platform: 'android', deviceTokens, }); deviceTokens.forEach(deviceToken => allDeviceTokens.add(deviceToken)); } } parsedDeliveries[id] = rowParsedDeliveries; } const deviceTokenToCookieID = await getDeviceTokenToCookieID(allDeviceTokens); const deliveryPromises: { [string]: Promise | Promise, } = {}; const notifInfo = {}; const rescindedIDs = []; for (const row of fetchResult) { const id = row.id.toString(); const threadID = row.thread.toString(); notifInfo[id] = { userID: row.user.toString(), threadID, messageID: row.message.toString(), }; for (const delivery of parsedDeliveries[id]) { let platformDetails: PlatformDetails = { platform: delivery.platform }; if (delivery.codeVersion) { platformDetails = { ...platformDetails, codeVersion: delivery.codeVersion, }; } if (delivery.stateVersion) { platformDetails = { ...platformDetails, stateVersion: delivery.stateVersion, }; } if (delivery.platform === 'ios') { const devices = delivery.deviceTokens.map(deviceToken => ({ deliveryID: deviceToken, cryptoID: deviceTokenToCookieID[deviceToken], })); const deliveryPromise = (async () => { const targetedNotifications = await prepareIOSNotification( keyserverID, delivery.notificationID, row.unread_count, threadID, platformDetails, devices, ); return await apnPush({ targetedNotifications, platformDetails: { platform: 'ios', codeVersion: delivery.codeVersion, }, }); })(); deliveryPromises[id] = deliveryPromise; } else if (delivery.platform === 'android') { const devices = delivery.deviceTokens.map(deviceToken => ({ deliveryID: deviceToken, cryptoID: deviceTokenToCookieID[deviceToken], })); const deliveryPromise = (async () => { const targetedNotifications = await prepareAndroidNotification( keyserverID, delivery.notificationID, row.unread_count, threadID, platformDetails, devices, ); return await fcmPush({ targetedNotifications, codeVersion: delivery.codeVersion, }); })(); deliveryPromises[id] = deliveryPromise; } } rescindedIDs.push(id); } const numRescinds = Object.keys(deliveryPromises).length; const dbIDsPromise: Promise> = (async () => { if (numRescinds === 0) { return undefined; } return await createIDs('notifications', numRescinds); })(); const rescindPromise: Promise = (async () => { if (rescindedIDs.length === 0) { return undefined; } const rescindQuery = SQL` UPDATE notifications SET rescinded = 1 WHERE id IN (${rescindedIDs}) `; return await dbQuery(rescindQuery); })(); const [deliveryResults, dbIDs] = await Promise.all([ promiseAll(deliveryPromises), dbIDsPromise, rescindPromise, ]); const newNotifRows = []; if (numRescinds > 0) { invariant(dbIDs, 'dbIDs should be set'); for (const rescindedID in deliveryResults) { const delivery: RescindDelivery = { source: 'rescind', rescindedID, }; const { errors } = deliveryResults[rescindedID]; if (errors) { delivery.errors = errors; } const dbID = dbIDs.shift(); const { userID, threadID, messageID } = notifInfo[rescindedID]; newNotifRows.push([ dbID, userID, threadID, messageID, null, JSON.stringify([delivery]), 1, ]); } } if (newNotifRows.length > 0) { const insertQuery = SQL` INSERT INTO notifications (id, user, thread, message, collapse_key, delivery, rescinded) VALUES ${newNotifRows} `; await dbQuery(insertQuery); } } async function getDeviceTokenToCookieID( deviceTokens: Set, ): Promise<{ +[string]: string }> { if (deviceTokens.size === 0) { return {}; } const deviceTokenToCookieID = {}; const fetchCookiesQuery = SQL` SELECT id, device_token FROM cookies WHERE device_token IN (${[...deviceTokens]}) `; const [fetchResult] = await dbQuery(fetchCookiesQuery); for (const row of fetchResult) { deviceTokenToCookieID[row.device_token.toString()] = row.id.toString(); } return deviceTokenToCookieID; } async function conditionallyEncryptNotification( encryptedNotifUtilsAPIInstance: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, notification: T, codeVersion: ?number, devices: $ReadOnlyArray, encryptCallback: ( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: T, codeVersion?: ?number, ) => Promise< $ReadOnlyArray<{ +notification: T, +cryptoID: string, +deliveryID: string, +encryptionOrder?: number, }>, >, ): Promise<$ReadOnlyArray<{ +deliveryID: string, +notification: T }>> { const shouldBeEncrypted = codeVersion && codeVersion >= 233; if (!shouldBeEncrypted) { return devices.map(({ deliveryID }) => ({ notification, deliveryID, })); } const notifications = await encryptCallback( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, codeVersion, ); return notifications.map(({ deliveryID, notification: notif }) => ({ deliveryID, notification: notif, })); } async function prepareIOSNotification( keyserverID: string, iosID: string, unreadCount: number, threadID: string, platformDetails: PlatformDetails, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { threadID = await validateOutput(platformDetails, tID, threadID); const { codeVersion } = platformDetails; const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic({ platform: 'ios', codeVersion, }); if (codeVersion && codeVersion > 198) { notification.mutableContent = true; notification.pushType = 'alert'; notification.badge = unreadCount; } else { notification.priority = 5; notification.contentAvailable = true; notification.pushType = 'background'; } notification.payload = { backgroundNotifType: 'CLEAR', notificationId: iosID, setUnreadStatus: true, threadID, keyserverID, }; return await conditionallyEncryptNotification( encryptedNotifUtilsAPI, { keyserverID }, notification, codeVersion, devices, prepareEncryptedIOSNotificationRescind, ); } async function prepareAndroidNotification( keyserverID: string, notifID: string, unreadCount: number, threadID: string, platformDetails: PlatformDetails, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { threadID = await validateOutput(platformDetails, tID, threadID); - return await createAndroidNotificationRescind( + const { targetedNotifications } = await createAndroidNotificationRescind( encryptedNotifUtilsAPI, { senderDeviceDescriptor: { keyserverID }, badge: unreadCount.toString(), platformDetails, rescindID: notifID, threadID, }, devices, ); + return targetedNotifications; } export { rescindPushNotifs }; diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js index 7b015c1da..f02ea22b0 100644 --- a/keyserver/src/push/send.js +++ b/keyserver/src/push/send.js @@ -1,1618 +1,1630 @@ // @flow import type { ResponseFailure } from '@parse/node-apn'; import apn from '@parse/node-apn'; import invariant from 'invariant'; import _cloneDeep from 'lodash/fp/cloneDeep.js'; import _flow from 'lodash/fp/flow.js'; import _groupBy from 'lodash/fp/groupBy.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _pickBy from 'lodash/fp/pickBy.js'; import type { QueryResults } from 'mysql'; import t from 'tcomb'; import uuidv4 from 'uuid/v4.js'; import { type AndroidNotifInputData, androidNotifInputDataValidator, createAndroidVisualNotification, createAndroidBadgeOnlyNotification, } from 'lib/push/android-notif-creators.js'; import { apnMaxNotificationPayloadByteSize } from 'lib/push/apns-notif-creators.js'; import type { PushUserInfo, PushInfo, Device, CollapsableNotifInfo, } from 'lib/push/send-utils.js'; import { stringToVersionKey, getDevicesByPlatform, userAllowsNotif, } from 'lib/push/utils.js'; import { type WebNotifInputData, webNotifInputDataValidator, createWebNotification, } from 'lib/push/web-notif-creators.js'; import { type WNSNotifInputData, wnsNotifInputDataValidator, createWNSNotification, } from 'lib/push/wns-notif-creators.js'; import { createMessageInfo, shimUnsupportedRawMessageInfos, sortMessageInfoList, } from 'lib/shared/message-utils.js'; import { messageSpecs } from 'lib/shared/messages/message-specs.js'; import { notifTextsForMessageInfo, getAPNsNotificationTopic, } from 'lib/shared/notif-utils.js'; import { rawThreadInfoFromServerThreadInfo, threadInfoFromRawThreadInfo, } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { Platform, PlatformDetails } from 'lib/types/device-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, rawMessageInfoValidator, } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { NotificationTargetDevice, TargetedAndroidNotification, TargetedWebNotification, TargetedWNSNotification, ResolvedNotifTexts, } from 'lib/types/notif-types.js'; import { resolvedNotifTextsValidator } from 'lib/types/notif-types.js'; import type { ServerThreadInfo } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import { type GlobalUserInfo } from 'lib/types/user-types.js'; import { values } from 'lib/utils/objects.js'; import { tID, tPlatformDetails, tShape } from 'lib/utils/validation-utils.js'; import { prepareEncryptedAPNsNotifications } from './crypto.js'; import encryptedNotifUtilsAPI from './encrypted-notif-utils-api.js'; import { rescindPushNotifs } from './rescind.js'; import type { TargetedAPNsNotification } from './types.js'; import { apnPush, fcmPush, getUnreadCounts, webPush, type WebPushError, wnsPush, type WNSPushError, } from './utils.js'; import createIDs from '../creators/id-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, mergeOrConditions, SQL } from '../database/database.js'; import { fetchCollapsableNotifs } from '../fetchers/message-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { thisKeyserverID } from '../user/identity.js'; import { getENSNames } from '../utils/ens-cache.js'; import { getFCNames } from '../utils/fc-cache.js'; import { validateOutput } from '../utils/validation-utils.js'; type Delivery = PushDelivery | { collapsedInto: string }; type NotificationRow = { +dbID: string, +userID: string, +threadID?: ?string, +messageID?: ?string, +collapseKey?: ?string, +deliveries: Delivery[], }; async function sendPushNotifs(pushInfo: PushInfo) { if (Object.keys(pushInfo).length === 0) { return; } const keyserverID = await thisKeyserverID(); const [ unreadCounts, { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }, dbIDs, ] = await Promise.all([ getUnreadCounts(Object.keys(pushInfo)), fetchInfos(pushInfo), createDBIDs(pushInfo), ]); const preparePromises: Array>> = []; const notifications: Map = new Map(); for (const userID in usersToCollapsableNotifInfo) { const threadInfos = _flow( _mapValues((serverThreadInfo: ServerThreadInfo) => { const rawThreadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, userID, { minimallyEncodePermissions: true }, ); if (!rawThreadInfo) { return null; } invariant( rawThreadInfo.minimallyEncoded, 'rawThreadInfo from rawThreadInfoFromServerThreadInfo must be ' + 'minimallyEncoded when minimallyEncodePermissions option is set', ); return threadInfoFromRawThreadInfo(rawThreadInfo, userID, userInfos); }), _pickBy(threadInfo => threadInfo), )(serverThreadInfos); for (const notifInfo of usersToCollapsableNotifInfo[userID]) { preparePromises.push( preparePushNotif({ keyserverID, notifInfo, userID, pushUserInfo: pushInfo[userID], unreadCount: unreadCounts[userID], threadInfos, userInfos, dbIDs, rowsToSave: notifications, }), ); } } const prepareResults = await Promise.all(preparePromises); const flattenedPrepareResults = prepareResults.filter(Boolean).flat(); const deliveryResults = await deliverPushNotifsInEncryptionOrder( flattenedPrepareResults, ); const cleanUpPromise = (async () => { if (dbIDs.length === 0) { return; } const query = SQL`DELETE FROM ids WHERE id IN (${dbIDs})`; await dbQuery(query); })(); await Promise.all([ cleanUpPromise, saveNotifResults(deliveryResults, notifications, true), ]); } type PreparePushResult = { +platform: Platform, +notificationInfo: NotificationInfo, +notification: | TargetedAPNsNotification | TargetedAndroidNotification | TargetedWebNotification | TargetedWNSNotification, }; async function preparePushNotif(input: { keyserverID: string, notifInfo: CollapsableNotifInfo, userID: string, pushUserInfo: PushUserInfo, unreadCount: number, threadInfos: { +[threadID: string]: ThreadInfo, }, userInfos: { +[userID: string]: GlobalUserInfo }, dbIDs: string[], // mutable rowsToSave: Map, // mutable }): Promise> { const { keyserverID, notifInfo, userID, pushUserInfo, unreadCount, threadInfos, userInfos, dbIDs, rowsToSave, } = input; const hydrateMessageInfo = (rawMessageInfo: RawMessageInfo) => createMessageInfo(rawMessageInfo, userID, userInfos, threadInfos); const newMessageInfos = []; const newRawMessageInfos = []; for (const newRawMessageInfo of notifInfo.newMessageInfos) { const newMessageInfo = hydrateMessageInfo(newRawMessageInfo); if (newMessageInfo) { newMessageInfos.push(newMessageInfo); newRawMessageInfos.push(newRawMessageInfo); } } if (newMessageInfos.length === 0) { return null; } const existingMessageInfos = notifInfo.existingMessageInfos .map(hydrateMessageInfo) .filter(Boolean); const allMessageInfos = sortMessageInfoList([ ...newMessageInfos, ...existingMessageInfos, ]); const [firstNewMessageInfo, ...remainingNewMessageInfos] = newMessageInfos; const { threadID } = firstNewMessageInfo; const threadInfo = threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; const username = userInfos[userID] && userInfos[userID].username; const { notifAllowed, badgeOnly } = await userAllowsNotif({ subscription: { ...threadInfo.currentUser.subscription, role: threadInfo.currentUser.role, }, userID, newMessageInfos, userInfos, username, getENSNames, }); if (!notifAllowed) { return null; } const notifTargetUserInfo = { id: userID, username }; const notifTexts = await notifTextsForMessageInfo( allMessageInfos, threadInfo, parentThreadInfo, notifTargetUserInfo, getENSNames, getFCNames, ); if (!notifTexts) { return null; } const dbID = dbIDs.shift(); invariant(dbID, 'should have sufficient DB IDs'); const byPlatform = getDevicesByPlatform(pushUserInfo.devices); const firstMessageID = firstNewMessageInfo.id; invariant(firstMessageID, 'RawMessageInfo.id should be set on server'); const notificationInfo = { source: 'new_message', dbID, userID, threadID, messageID: firstMessageID, collapseKey: notifInfo.collapseKey, }; const preparePromises: Array>> = []; const iosVersionsToTokens = byPlatform.get('ios'); if (iosVersionsToTokens) { for (const [versionKey, devices] of iosVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails: PlatformDetails = { platform: 'ios', codeVersion, stateVersion, }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareAPNsNotification( { keyserverID, notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount, platformDetails, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'ios', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const androidVersionsToTokens = byPlatform.get('android'); if (androidVersionsToTokens) { for (const [versionKey, devices] of androidVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'android', codeVersion, stateVersion, }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareAndroidVisualNotification( { senderDeviceDescriptor: { keyserverID }, notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount, platformDetails, notifID: dbID, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'android', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const webVersionsToTokens = byPlatform.get('web'); if (webVersionsToTokens) { for (const [versionKey, devices] of webVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'web', codeVersion, stateVersion, }; const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareWebNotification( { notifTexts, threadID: threadInfo.id, senderDeviceDescriptor: { keyserverID }, unreadCount, platformDetails, id: uuidv4(), }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'web', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const macosVersionsToTokens = byPlatform.get('macos'); if (macosVersionsToTokens) { for (const [versionKey, devices] of macosVersionsToTokens) { const { codeVersion, stateVersion, majorDesktopVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'macos', codeVersion, stateVersion, majorDesktopVersion, }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareAPNsNotification( { keyserverID, notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount, platformDetails, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'macos', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const windowsVersionsToTokens = byPlatform.get('windows'); if (windowsVersionsToTokens) { for (const [versionKey, devices] of windowsVersionsToTokens) { const { codeVersion, stateVersion, majorDesktopVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'windows', codeVersion, stateVersion, majorDesktopVersion, }; const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareWNSNotification(devices, { notifTexts, threadID: threadInfo.id, senderDeviceDescriptor: { keyserverID }, unreadCount, platformDetails, }); return targetedNotifications.map(notification => ({ notification, platform: 'windows', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } for (const newMessageInfo of remainingNewMessageInfos) { const newDBID = dbIDs.shift(); invariant(newDBID, 'should have sufficient DB IDs'); const messageID = newMessageInfo.id; invariant(messageID, 'RawMessageInfo.id should be set on server'); rowsToSave.set(newDBID, { dbID: newDBID, userID, threadID: newMessageInfo.threadID, messageID, collapseKey: notifInfo.collapseKey, deliveries: [{ collapsedInto: dbID }], }); } const prepareResults = await Promise.all(preparePromises); return prepareResults.flat(); } // For better readability we don't differentiate between // encrypted and unencrypted notifs and order them together function compareEncryptionOrder( pushNotif1: PreparePushResult, pushNotif2: PreparePushResult, ): number { const order1 = pushNotif1.notification.encryptionOrder ?? 0; const order2 = pushNotif2.notification.encryptionOrder ?? 0; return order1 - order2; } async function deliverPushNotifsInEncryptionOrder( preparedPushNotifs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const deliveryPromises: Array>> = []; const groupedByDevice = _groupBy( preparedPushNotif => preparedPushNotif.deviceToken, )(preparedPushNotifs); for (const preparedPushNotifsForDevice of values(groupedByDevice)) { const orderedPushNotifsForDevice = preparedPushNotifsForDevice.sort( compareEncryptionOrder, ); const deviceDeliveryPromise = (async () => { const deliveries = []; for (const preparedPushNotif of orderedPushNotifsForDevice) { const { platform, notification, notificationInfo } = preparedPushNotif; let delivery: PushResult; if (platform === 'ios' || platform === 'macos') { delivery = await sendAPNsNotification( platform, [notification], notificationInfo, ); } else if (platform === 'android') { delivery = await sendAndroidNotification( [notification], notificationInfo, ); } else if (platform === 'web') { delivery = await sendWebNotifications( [notification], notificationInfo, ); } else if (platform === 'windows') { delivery = await sendWNSNotification( [notification], notificationInfo, ); } if (delivery) { deliveries.push(delivery); } } return deliveries; })(); deliveryPromises.push(deviceDeliveryPromise); } const deliveryResults = await Promise.all(deliveryPromises); return deliveryResults.flat(); } async function sendRescindNotifs(rescindInfo: PushInfo) { if (Object.keys(rescindInfo).length === 0) { return; } const usersToCollapsableNotifInfo = await fetchCollapsableNotifs(rescindInfo); const promises = []; for (const userID in usersToCollapsableNotifInfo) { for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const existingMessageInfo of notifInfo.existingMessageInfos) { const rescindCondition = SQL` n.user = ${userID} AND n.thread = ${existingMessageInfo.threadID} AND n.message = ${existingMessageInfo.id} `; promises.push(rescindPushNotifs(rescindCondition)); } } } await Promise.all(promises); } // The results in deliveryResults will be combined with the rows // in rowsToSave and then written to the notifications table async function saveNotifResults( deliveryResults: $ReadOnlyArray, inputRowsToSave: Map, rescindable: boolean, ) { const rowsToSave = new Map(inputRowsToSave); const allInvalidTokens = []; for (const deliveryResult of deliveryResults) { const { info, delivery, invalidTokens } = deliveryResult; const { dbID, userID } = info; const curNotifRow = rowsToSave.get(dbID); if (curNotifRow) { curNotifRow.deliveries.push(delivery); } else { // Ternary expressions for Flow const threadID = info.threadID ? info.threadID : null; const messageID = info.messageID ? info.messageID : null; const collapseKey = info.collapseKey ? info.collapseKey : null; rowsToSave.set(dbID, { dbID, userID, threadID, messageID, collapseKey, deliveries: [delivery], }); } if (invalidTokens) { allInvalidTokens.push({ userID, tokens: invalidTokens, }); } } const notificationRows = []; for (const notification of rowsToSave.values()) { notificationRows.push([ notification.dbID, notification.userID, notification.threadID, notification.messageID, notification.collapseKey, JSON.stringify(notification.deliveries), Number(!rescindable), ]); } const dbPromises: Array> = []; if (allInvalidTokens.length > 0) { dbPromises.push(removeInvalidTokens(allInvalidTokens)); } if (notificationRows.length > 0) { const query = SQL` INSERT INTO notifications (id, user, thread, message, collapse_key, delivery, rescinded) VALUES ${notificationRows} `; dbPromises.push(dbQuery(query)); } if (dbPromises.length > 0) { await Promise.all(dbPromises); } } async function fetchInfos(pushInfo: PushInfo) { const usersToCollapsableNotifInfo = await fetchCollapsableNotifs(pushInfo); const threadIDs = new Set(); const threadWithChangedNamesToMessages = new Map>(); const addThreadIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { const threadID = rawMessageInfo.threadID; threadIDs.add(threadID); const messageSpec = messageSpecs[rawMessageInfo.type]; if (messageSpec.threadIDs) { for (const id of messageSpec.threadIDs(rawMessageInfo)) { threadIDs.add(id); } } if ( rawMessageInfo.type === messageTypes.CHANGE_SETTINGS && rawMessageInfo.field === 'name' ) { const messages = threadWithChangedNamesToMessages.get(threadID); if (messages) { messages.push(rawMessageInfo.id); } else { threadWithChangedNamesToMessages.set(threadID, [rawMessageInfo.id]); } } }; for (const userID in usersToCollapsableNotifInfo) { for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const rawMessageInfo of notifInfo.existingMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } for (const rawMessageInfo of notifInfo.newMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } } } // These threadInfos won't have currentUser set const threadPromise = fetchServerThreadInfos({ threadIDs }); const oldNamesPromise: Promise = (async () => { if (threadWithChangedNamesToMessages.size === 0) { return undefined; } const typesThatAffectName = [ messageTypes.CHANGE_SETTINGS, messageTypes.CREATE_THREAD, ]; const oldNameQuery = SQL` SELECT IF( JSON_TYPE(JSON_EXTRACT(m.content, "$.name")) = 'NULL', "", JSON_UNQUOTE(JSON_EXTRACT(m.content, "$.name")) ) AS name, m.thread FROM ( SELECT MAX(id) AS id FROM messages WHERE type IN (${typesThatAffectName}) AND JSON_EXTRACT(content, "$.name") IS NOT NULL AND`; const threadClauses = []; for (const [threadID, messages] of threadWithChangedNamesToMessages) { threadClauses.push( SQL`(thread = ${threadID} AND id NOT IN (${messages}))`, ); } oldNameQuery.append(mergeOrConditions(threadClauses)); oldNameQuery.append(SQL` GROUP BY thread ) x LEFT JOIN messages m ON m.id = x.id `); return await dbQuery(oldNameQuery); })(); const [threadResult, oldNames] = await Promise.all([ threadPromise, oldNamesPromise, ]); const serverThreadInfos = { ...threadResult.threadInfos }; if (oldNames) { const [result] = oldNames; for (const row of result) { const threadID = row.thread.toString(); serverThreadInfos[threadID] = { ...serverThreadInfos[threadID], name: row.name, }; } } const userInfos = await fetchNotifUserInfos( serverThreadInfos, usersToCollapsableNotifInfo, ); return { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }; } async function fetchNotifUserInfos( serverThreadInfos: { +[threadID: string]: ServerThreadInfo }, usersToCollapsableNotifInfo: { +[userID: string]: $ReadOnlyArray, }, ) { const missingUserIDs = new Set(); for (const threadID in serverThreadInfos) { const serverThreadInfo = serverThreadInfos[threadID]; for (const member of serverThreadInfo.members) { missingUserIDs.add(member.id); } } const addUserIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { missingUserIDs.add(rawMessageInfo.creatorID); const userIDs = messageSpecs[rawMessageInfo.type].userIDs?.(rawMessageInfo) ?? []; for (const userID of userIDs) { missingUserIDs.add(userID); } }; for (const userID in usersToCollapsableNotifInfo) { missingUserIDs.add(userID); for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const rawMessageInfo of notifInfo.existingMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } for (const rawMessageInfo of notifInfo.newMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } } } return await fetchUserInfos([...missingUserIDs]); } async function createDBIDs(pushInfo: PushInfo): Promise { let numIDsNeeded = 0; for (const userID in pushInfo) { numIDsNeeded += pushInfo[userID].messageInfos.length; } return await createIDs('notifications', numIDsNeeded); } type CommonNativeNotifInputData = { +keyserverID: string, +notifTexts: ResolvedNotifTexts, +newRawMessageInfos: RawMessageInfo[], +threadID: string, +collapseKey: ?string, +badgeOnly: boolean, +unreadCount: number, +platformDetails: PlatformDetails, }; const commonNativeNotifInputDataValidator = tShape({ keyserverID: t.String, notifTexts: resolvedNotifTextsValidator, newRawMessageInfos: t.list(rawMessageInfoValidator), threadID: tID, collapseKey: t.maybe(t.String), badgeOnly: t.Boolean, unreadCount: t.Number, platformDetails: tPlatformDetails, }); async function prepareAPNsNotification( inputData: CommonNativeNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = await validateOutput( inputData.platformDetails, commonNativeNotifInputDataValidator, inputData, ); const { keyserverID, notifTexts, newRawMessageInfos, threadID, collapseKey, badgeOnly, unreadCount, platformDetails, } = convertedData; const canDecryptNonCollapsibleTextIOSNotifs = platformDetails.codeVersion && platformDetails.codeVersion > 222; const isNonCollapsibleTextNotification = newRawMessageInfos.every( newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, ) && !collapseKey; const canDecryptAllIOSNotifs = platformDetails.codeVersion && platformDetails.codeVersion >= 267; const canDecryptIOSNotif = platformDetails.platform === 'ios' && (canDecryptAllIOSNotifs || (isNonCollapsibleTextNotification && canDecryptNonCollapsibleTextIOSNotifs)); const canDecryptMacOSNotifs = platformDetails.platform === 'macos' && hasMinCodeVersion(platformDetails, { web: 47, majorDesktop: 9, }); const shouldBeEncrypted = canDecryptIOSNotif || canDecryptMacOSNotifs; const uniqueID = uuidv4(); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic(platformDetails); const { merged, ...rest } = notifTexts; // We don't include alert's body on macos because we // handle displaying the notification ourselves and // we don't want macOS to display it automatically. if (!badgeOnly && platformDetails.platform !== 'macos') { notification.body = merged; notification.sound = 'default'; } notification.payload = { ...notification.payload, ...rest, }; notification.badge = unreadCount; notification.threadId = threadID; notification.id = uniqueID; notification.pushType = 'alert'; notification.payload.id = uniqueID; notification.payload.threadID = threadID; if (platformDetails.codeVersion && platformDetails.codeVersion > 198) { notification.mutableContent = true; } if (collapseKey && (canDecryptAllIOSNotifs || canDecryptMacOSNotifs)) { notification.payload.collapseID = collapseKey; } else if (collapseKey) { notification.collapseId = collapseKey; } const messageInfos = JSON.stringify(newRawMessageInfos); // We make a copy before checking notification's length, because calling // length compiles the notification and makes it immutable. Further // changes to its properties won't be reflected in the final plaintext // data that is sent. const copyWithMessageInfos = _cloneDeep(notification); copyWithMessageInfos.payload = { ...copyWithMessageInfos.payload, messageInfos, }; const notificationSizeValidator = (notif: apn.Notification) => notif.length() <= apnMaxNotificationPayloadByteSize; if (!shouldBeEncrypted) { const notificationToSend = notificationSizeValidator( _cloneDeep(copyWithMessageInfos), ) ? copyWithMessageInfos : notification; return devices.map(({ deliveryID }) => ({ notification: notificationToSend, deliveryID, })); } // The `messageInfos` field in notification payload is // not used on MacOS so we can return early. if (platformDetails.platform === 'macos') { const macOSNotifsWithoutMessageInfos = await prepareEncryptedAPNsNotifications( encryptedNotifUtilsAPI, { keyserverID }, devices, notification, platformDetails.codeVersion, ); return macOSNotifsWithoutMessageInfos.map( ({ notification: notif, deliveryID }) => ({ notification: notif, deliveryID, }), ); } const notifsWithMessageInfos = await prepareEncryptedAPNsNotifications( encryptedNotifUtilsAPI, { keyserverID }, devices, copyWithMessageInfos, platformDetails.codeVersion, notificationSizeValidator, ); const devicesWithExcessiveSizeNoHolders = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) .map(({ cryptoID, deliveryID }) => ({ cryptoID, deliveryID, })); if (devicesWithExcessiveSizeNoHolders.length === 0) { return notifsWithMessageInfos.map( ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }), ); } const canQueryBlobService = hasMinCodeVersion(platformDetails, { native: 331, }); let blobHash, blobHolders, encryptionKey, blobUploadError; if (canQueryBlobService) { ({ blobHash, blobHolders, encryptionKey, blobUploadError } = await encryptedNotifUtilsAPI.uploadLargeNotifPayload( copyWithMessageInfos.compile(), devicesWithExcessiveSizeNoHolders.length, )); } if (blobUploadError) { console.warn( `Failed to upload payload of notification: ${uniqueID} ` + `due to error: ${blobUploadError}`, ); } let devicesWithExcessiveSize = devicesWithExcessiveSizeNoHolders; if ( blobHash && encryptionKey && blobHolders && blobHolders.length === devicesWithExcessiveSize.length ) { notification.payload = { ...notification.payload, blobHash, encryptionKey, }; devicesWithExcessiveSize = blobHolders.map((holder, idx) => ({ ...devicesWithExcessiveSize[idx], blobHolder: holder, })); } const notifsWithoutMessageInfos = await prepareEncryptedAPNsNotifications( encryptedNotifUtilsAPI, { keyserverID }, devicesWithExcessiveSize, notification, platformDetails.codeVersion, ); const targetedNotifsWithMessageInfos = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) .map( ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }), ); const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }), ); return [ ...targetedNotifsWithMessageInfos, ...targetedNotifsWithoutMessageInfos, ]; } async function prepareAndroidVisualNotification( inputData: AndroidNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = await validateOutput( inputData.platformDetails, androidNotifInputDataValidator, inputData, ); - return createAndroidVisualNotification( + const { targetedNotifications } = await createAndroidVisualNotification( encryptedNotifUtilsAPI, convertedData, devices, ); + return targetedNotifications; } async function prepareWebNotification( inputData: WebNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = await validateOutput( inputData.platformDetails, webNotifInputDataValidator, inputData, ); - return createWebNotification(encryptedNotifUtilsAPI, convertedData, devices); + const { targetedNotifications } = await createWebNotification( + encryptedNotifUtilsAPI, + convertedData, + devices, + ); + + return targetedNotifications; } async function prepareWNSNotification( devices: $ReadOnlyArray, inputData: WNSNotifInputData, ): Promise<$ReadOnlyArray> { const convertedData = await validateOutput( inputData.platformDetails, wnsNotifInputDataValidator, inputData, ); - return createWNSNotification(encryptedNotifUtilsAPI, convertedData, devices); + const { targetedNotifications } = await createWNSNotification( + encryptedNotifUtilsAPI, + convertedData, + devices, + ); + return targetedNotifications; } type NotificationInfo = | { +source: 'new_message', +dbID: string, +userID: string, +threadID: string, +messageID: string, +collapseKey: ?string, +codeVersion: number, +stateVersion: number, } | { +source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', +dbID: string, +userID: string, +codeVersion: number, +stateVersion: number, }; type APNsDelivery = { +source: $PropertyType, +deviceType: 'ios' | 'macos', +iosID: string, +deviceTokens: $ReadOnlyArray, +codeVersion: number, +stateVersion: number, +errors?: $ReadOnlyArray, +encryptedPayloadHashes?: $ReadOnlyArray, +deviceTokensToPayloadHash?: { +[deviceToken: string]: string, }, }; type APNsResult = { info: NotificationInfo, delivery: APNsDelivery, invalidTokens?: $ReadOnlyArray, }; async function sendAPNsNotification( platform: 'ios' | 'macos', targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion, stateVersion } = notificationInfo; const response = await apnPush({ targetedNotifications, platformDetails: { platform, codeVersion }, }); invariant( new Set(targetedNotifications.map(({ notification }) => notification.id)) .size === 1, 'Encrypted versions of the same notification must share id value', ); const iosID = targetedNotifications[0].notification.id; const deviceTokens = targetedNotifications.map( ({ deliveryID }) => deliveryID, ); let delivery: APNsDelivery = { source, deviceType: platform, iosID, deviceTokens, codeVersion, stateVersion, }; if (response.errors) { delivery = { ...delivery, errors: response.errors, }; } const deviceTokensToPayloadHash: { [string]: string } = {}; for (const targetedNotification of targetedNotifications) { if (targetedNotification.encryptedPayloadHash) { deviceTokensToPayloadHash[targetedNotification.deliveryID] = targetedNotification.encryptedPayloadHash; } } if (Object.keys(deviceTokensToPayloadHash).length !== 0) { delivery = { ...delivery, deviceTokensToPayloadHash, }; } const result: APNsResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type PushResult = AndroidResult | APNsResult | WebResult | WNSResult; type PushDelivery = AndroidDelivery | APNsDelivery | WebDelivery | WNSDelivery; type AndroidDelivery = { source: $PropertyType, deviceType: 'android', androidIDs: $ReadOnlyArray, deviceTokens: $ReadOnlyArray, codeVersion: number, stateVersion: number, errors?: $ReadOnlyArray, }; type AndroidResult = { info: NotificationInfo, delivery: AndroidDelivery, invalidTokens?: $ReadOnlyArray, }; async function sendAndroidNotification( targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const collapseKey = notificationInfo.collapseKey ? notificationInfo.collapseKey : null; // for Flow... const { source, codeVersion, stateVersion } = notificationInfo; const response = await fcmPush({ targetedNotifications, collapseKey, codeVersion, }); const deviceTokens = targetedNotifications.map( ({ deliveryID }) => deliveryID, ); const androidIDs = response.fcmIDs ? response.fcmIDs : []; const delivery: AndroidDelivery = { source, deviceType: 'android', androidIDs, deviceTokens, codeVersion, stateVersion, }; if (response.errors) { delivery.errors = response.errors; } const result: AndroidResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type WebDelivery = { +source: $PropertyType, +deviceType: 'web', +deviceTokens: $ReadOnlyArray, +codeVersion?: number, +stateVersion: number, +errors?: $ReadOnlyArray, }; type WebResult = { +info: NotificationInfo, +delivery: WebDelivery, +invalidTokens?: $ReadOnlyArray, }; async function sendWebNotifications( targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion, stateVersion } = notificationInfo; const response = await webPush(targetedNotifications); const deviceTokens = targetedNotifications.map( ({ deliveryID }) => deliveryID, ); const delivery: WebDelivery = { source, deviceType: 'web', deviceTokens, codeVersion, errors: response.errors, stateVersion, }; const result: WebResult = { info: notificationInfo, delivery, invalidTokens: response.invalidTokens, }; return result; } type WNSDelivery = { +source: $PropertyType, +deviceType: 'windows', +wnsIDs: $ReadOnlyArray, +deviceTokens: $ReadOnlyArray, +codeVersion?: number, +stateVersion: number, +errors?: $ReadOnlyArray, }; type WNSResult = { +info: NotificationInfo, +delivery: WNSDelivery, +invalidTokens?: $ReadOnlyArray, }; async function sendWNSNotification( targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion, stateVersion } = notificationInfo; const response = await wnsPush(targetedNotifications); const deviceTokens = targetedNotifications.map( ({ deliveryID }) => deliveryID, ); const wnsIDs = response.wnsIDs ?? []; const delivery: WNSDelivery = { source, deviceType: 'windows', wnsIDs, deviceTokens, codeVersion, errors: response.errors, stateVersion, }; const result: WNSResult = { info: notificationInfo, delivery, invalidTokens: response.invalidTokens, }; return result; } type InvalidToken = { +userID: string, +tokens: $ReadOnlyArray, }; async function removeInvalidTokens( invalidTokens: $ReadOnlyArray, ): Promise { const sqlTuples = invalidTokens.map( invalidTokenUser => SQL`( user = ${invalidTokenUser.userID} AND device_token IN (${invalidTokenUser.tokens}) )`, ); const sqlCondition = mergeOrConditions(sqlTuples); const selectQuery = SQL` SELECT id, user, device_token FROM cookies WHERE `; selectQuery.append(sqlCondition); const [result] = await dbQuery(selectQuery); const userCookiePairsToInvalidDeviceTokens = new Map>(); for (const row of result) { const userCookiePair = `${row.user}|${row.id}`; const existing = userCookiePairsToInvalidDeviceTokens.get(userCookiePair); if (existing) { existing.add(row.device_token); } else { userCookiePairsToInvalidDeviceTokens.set( userCookiePair, new Set([row.device_token]), ); } } const time = Date.now(); const promises: Array> = []; for (const entry of userCookiePairsToInvalidDeviceTokens) { const [userCookiePair, deviceTokens] = entry; const [userID, cookieID] = userCookiePair.split('|'); const updateDatas = [...deviceTokens].map(deviceToken => ({ type: updateTypes.BAD_DEVICE_TOKEN, userID, time, deviceToken, targetCookie: cookieID, })); promises.push(createUpdates(updateDatas)); } const updateQuery = SQL` UPDATE cookies SET device_token = NULL WHERE `; updateQuery.append(sqlCondition); promises.push(dbQuery(updateQuery)); await Promise.all(promises); } async function updateBadgeCount( viewer: Viewer, source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', ) { const { userID } = viewer; const deviceTokenQuery = SQL` SELECT platform, device_token, versions, id FROM cookies WHERE user = ${userID} AND device_token IS NOT NULL `; if (viewer.data.cookieID) { deviceTokenQuery.append(SQL`AND id != ${viewer.cookieID} `); } const [unreadCounts, [deviceTokenResult], [dbID], keyserverID] = await Promise.all([ getUnreadCounts([userID]), dbQuery(deviceTokenQuery), createIDs('notifications', 1), thisKeyserverID(), ]); const unreadCount = unreadCounts[userID]; const devices: $ReadOnlyArray = deviceTokenResult.map(row => { const versions = JSON.parse(row.versions); return { deliveryID: row.device_token, cryptoID: row.id, platformDetails: { platform: row.platform, codeVersion: versions?.codeVersion, stateVersion: versions?.stateVersion, }, }; }); const byPlatform = getDevicesByPlatform(devices); const preparePromises: Array>> = []; const iosVersionsToTokens = byPlatform.get('ios'); if (iosVersionsToTokens) { for (const [versionKey, deviceInfos] of iosVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic({ platform: 'ios', codeVersion, stateVersion, }); notification.badge = unreadCount; notification.pushType = 'alert'; const preparePromise: Promise = (async () => { let targetedNotifications: $ReadOnlyArray; if (codeVersion > 222) { const notificationsArray = await prepareEncryptedAPNsNotifications( encryptedNotifUtilsAPI, { keyserverID }, deviceInfos, notification, codeVersion, ); targetedNotifications = notificationsArray.map( ({ notification: notif, deliveryID, encryptionOrder }) => ({ notification: notif, deliveryID, encryptionOrder, }), ); } else { targetedNotifications = deviceInfos.map(({ deliveryID }) => ({ notification, deliveryID, })); } return targetedNotifications.map(targetedNotification => ({ notification: targetedNotification, platform: 'ios', notificationInfo: { source, dbID, userID, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const androidVersionsToTokens = byPlatform.get('android'); if (androidVersionsToTokens) { for (const [versionKey, deviceInfos] of androidVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const preparePromise: Promise = (async () => { - const targetedNotifications: $ReadOnlyArray = + const { targetedNotifications } = await createAndroidBadgeOnlyNotification( encryptedNotifUtilsAPI, { senderDeviceDescriptor: { keyserverID }, badge: unreadCount.toString(), platformDetails: { codeVersion, stateVersion, platform: 'android', }, }, deviceInfos, ); return targetedNotifications.map(targetedNotification => ({ notification: targetedNotification, platform: 'android', notificationInfo: { source, dbID, userID, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const macosVersionsToTokens = byPlatform.get('macos'); if (macosVersionsToTokens) { for (const [versionKey, deviceInfos] of macosVersionsToTokens) { const { codeVersion, stateVersion, majorDesktopVersion } = stringToVersionKey(versionKey); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic({ platform: 'macos', codeVersion, stateVersion, majorDesktopVersion, }); notification.badge = unreadCount; notification.pushType = 'alert'; notification.payload.keyserverID = keyserverID; const preparePromise: Promise = (async () => { const shouldBeEncrypted = hasMinCodeVersion(viewer.platformDetails, { web: 47, majorDesktop: 9, }); let targetedNotifications: $ReadOnlyArray; if (shouldBeEncrypted) { const notificationsArray = await prepareEncryptedAPNsNotifications( encryptedNotifUtilsAPI, { keyserverID }, deviceInfos, notification, codeVersion, ); targetedNotifications = notificationsArray.map( ({ notification: notif, deliveryID, encryptionOrder }) => ({ notification: notif, deliveryID, encryptionOrder, }), ); } else { targetedNotifications = deviceInfos.map(({ deliveryID }) => ({ deliveryID, notification, })); } return targetedNotifications.map(targetedNotification => ({ notification: targetedNotification, platform: 'macos', notificationInfo: { source, dbID, userID, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const prepareResults = await Promise.all(preparePromises); const flattenedPrepareResults = prepareResults.filter(Boolean).flat(); const deliveryResults = await deliverPushNotifsInEncryptionOrder( flattenedPrepareResults, ); await saveNotifResults(deliveryResults, new Map(), false); } export { sendPushNotifs, sendRescindNotifs, updateBadgeCount }; diff --git a/lib/push/android-notif-creators.js b/lib/push/android-notif-creators.js index 7ab2aae3c..55addcf16 100644 --- a/lib/push/android-notif-creators.js +++ b/lib/push/android-notif-creators.js @@ -1,392 +1,471 @@ // @flow import invariant from 'invariant'; import t, { type TInterface } from 'tcomb'; import { prepareEncryptedAndroidVisualNotifications, prepareEncryptedAndroidSilentNotifications, + prepareLargeNotifData, + type LargeNotifData, } from './crypto.js'; import { hasMinCodeVersion } from '../shared/version-utils.js'; import type { PlatformDetails } from '../types/device-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type RawMessageInfo, rawMessageInfoValidator, } from '../types/message-types.js'; import { type AndroidVisualNotification, type NotificationTargetDevice, type TargetedAndroidNotification, type ResolvedNotifTexts, resolvedNotifTextsValidator, type SenderDeviceDescriptor, senderDeviceDescriptorValidator, type EncryptedNotifUtilsAPI, type AndroidBadgeOnlyNotification, } from '../types/notif-types.js'; import { tID, tPlatformDetails, tShape } from '../utils/validation-utils.js'; export const fcmMaxNotificationPayloadByteSize = 4000; export type CommonNativeNotifInputData = $ReadOnly<{ +senderDeviceDescriptor: SenderDeviceDescriptor, +notifTexts: ResolvedNotifTexts, +newRawMessageInfos: $ReadOnlyArray, +threadID: string, +collapseKey: ?string, +badgeOnly: boolean, +unreadCount?: number, +platformDetails: PlatformDetails, }>; export const commonNativeNotifInputDataValidator: TInterface = tShape({ senderDeviceDescriptor: senderDeviceDescriptorValidator, notifTexts: resolvedNotifTextsValidator, newRawMessageInfos: t.list(rawMessageInfoValidator), threadID: tID, collapseKey: t.maybe(t.String), badgeOnly: t.Boolean, unreadCount: t.maybe(t.Number), platformDetails: tPlatformDetails, }); export type AndroidNotifInputData = $ReadOnly<{ ...CommonNativeNotifInputData, +notifID: string, }>; export const androidNotifInputDataValidator: TInterface = tShape({ ...commonNativeNotifInputDataValidator.meta.props, notifID: t.String, }); async function createAndroidVisualNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: AndroidNotifInputData, devices: $ReadOnlyArray, -): Promise<$ReadOnlyArray> { + largeNotifToEncryptionResultPromises?: { + [string]: Promise, + }, +): Promise<{ + +targetedNotifications: $ReadOnlyArray, + +largeNotifData?: LargeNotifData, +}> { const { senderDeviceDescriptor, notifTexts, newRawMessageInfos, threadID, collapseKey, badgeOnly, unreadCount, platformDetails, notifID, } = inputData; const canDecryptNonCollapsibleTextNotifs = hasMinCodeVersion( platformDetails, { native: 228 }, ); const isNonCollapsibleTextNotif = newRawMessageInfos.every( newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, ) && !collapseKey; const canDecryptAllNotifTypes = hasMinCodeVersion(platformDetails, { native: 267, }); const shouldBeEncrypted = canDecryptAllNotifTypes || (canDecryptNonCollapsibleTextNotifs && isNonCollapsibleTextNotif); const { merged, ...rest } = notifTexts; const notification = { data: { ...rest, threadID, }, }; if (unreadCount !== undefined && unreadCount !== null) { notification.data = { ...notification.data, badge: unreadCount.toString(), }; } let id; if (collapseKey && canDecryptAllNotifTypes) { id = notifID; notification.data = { ...notification.data, collapseKey, }; } else if (collapseKey) { id = collapseKey; } else { id = notifID; } notification.data = { ...notification.data, id, badgeOnly: badgeOnly ? '1' : '0', }; const messageInfos = JSON.stringify(newRawMessageInfos); const copyWithMessageInfos = { ...notification, data: { ...notification.data, messageInfos }, }; const priority = 'high'; if (!shouldBeEncrypted) { const notificationToSend = encryptedNotifUtilsAPI.getNotifByteSize( JSON.stringify(copyWithMessageInfos), ) <= fcmMaxNotificationPayloadByteSize ? copyWithMessageInfos : notification; - return devices.map(({ deliveryID }) => ({ - priority, - notification: notificationToSend, - deliveryID, - })); + return { + targetedNotifications: devices.map(({ deliveryID }) => ({ + priority, + notification: notificationToSend, + deliveryID, + })), + }; } const notificationsSizeValidator = (notif: AndroidVisualNotification) => { const serializedNotif = JSON.stringify(notif); return ( !serializedNotif || encryptedNotifUtilsAPI.getNotifByteSize(serializedNotif) <= fcmMaxNotificationPayloadByteSize ); }; const notifsWithMessageInfos = await prepareEncryptedAndroidVisualNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, copyWithMessageInfos, notificationsSizeValidator, ); const devicesWithExcessiveSizeNoHolders = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) .map(({ cryptoID, deliveryID }) => ({ cryptoID, deliveryID })); if (devicesWithExcessiveSizeNoHolders.length === 0) { - return notifsWithMessageInfos.map( - ({ notification: notif, deliveryID, encryptionOrder }) => ({ - priority, - notification: notif, - deliveryID, - encryptionOrder, - }), - ); + return { + targetedNotifications: notifsWithMessageInfos.map( + ({ notification: notif, deliveryID, encryptionOrder }) => ({ + priority, + notification: notif, + deliveryID, + encryptionOrder, + }), + ), + }; } const canQueryBlobService = hasMinCodeVersion(platformDetails, { native: 331, }); - let blobHash, blobHolders, encryptionKey, blobUploadError; - if (canQueryBlobService) { + let blobHash, + blobHolders, + encryptionKey, + blobUploadError, + encryptedCopyWithMessageInfos; + const copyWithMessageInfosDataBlob = JSON.stringify( + copyWithMessageInfos.data, + ); + if ( + canQueryBlobService && + largeNotifToEncryptionResultPromises && + largeNotifToEncryptionResultPromises[copyWithMessageInfosDataBlob] + ) { + const largeNotifData = + await largeNotifToEncryptionResultPromises[copyWithMessageInfosDataBlob]; + blobHash = largeNotifData.blobHash; + encryptionKey = largeNotifData.encryptionKey; + blobHolders = largeNotifData.blobHolders; + encryptedCopyWithMessageInfos = + largeNotifData.encryptedCopyWithMessageInfos; + } else if (canQueryBlobService && largeNotifToEncryptionResultPromises) { + largeNotifToEncryptionResultPromises[copyWithMessageInfosDataBlob] = + prepareLargeNotifData( + copyWithMessageInfosDataBlob, + devicesWithExcessiveSizeNoHolders.length, + encryptedNotifUtilsAPI, + ); + + const largeNotifData = + await largeNotifToEncryptionResultPromises[copyWithMessageInfosDataBlob]; + blobHash = largeNotifData.blobHash; + encryptionKey = largeNotifData.encryptionKey; + blobHolders = largeNotifData.blobHolders; + encryptedCopyWithMessageInfos = + largeNotifData.encryptedCopyWithMessageInfos; + } else if (canQueryBlobService) { ({ blobHash, blobHolders, encryptionKey, blobUploadError } = await encryptedNotifUtilsAPI.uploadLargeNotifPayload( - JSON.stringify(copyWithMessageInfos.data), + copyWithMessageInfosDataBlob, devicesWithExcessiveSizeNoHolders.length, )); } if (blobUploadError) { console.warn( `Failed to upload payload of notification: ${notifID} ` + `due to error: ${blobUploadError}`, ); } let devicesWithExcessiveSize = devicesWithExcessiveSizeNoHolders; if ( blobHash && encryptionKey && blobHolders && blobHolders.length === devicesWithExcessiveSizeNoHolders.length ) { notification.data = { ...notification.data, blobHash, encryptionKey, }; devicesWithExcessiveSize = blobHolders.map((holder, idx) => ({ ...devicesWithExcessiveSize[idx], blobHolder: holder, })); } const notifsWithoutMessageInfos = await prepareEncryptedAndroidVisualNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devicesWithExcessiveSize, notification, ); const targetedNotifsWithMessageInfos = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) .map(({ notification: notif, deliveryID, encryptionOrder }) => ({ priority, notification: notif, deliveryID, encryptionOrder, })); const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( ({ notification: notif, deliveryID, encryptionOrder }) => ({ priority, notification: notif, deliveryID, encryptionOrder, }), ); - return [ + const targetedNotifications = [ ...targetedNotifsWithMessageInfos, ...targetedNotifsWithoutMessageInfos, ]; + + if ( + !encryptedCopyWithMessageInfos || + !blobHash || + !blobHolders || + !encryptionKey + ) { + return { targetedNotifications }; + } + + return { + targetedNotifications, + largeNotifData: { + blobHash, + blobHolders, + encryptionKey, + encryptedCopyWithMessageInfos, + }, + }; } type AndroidNotificationRescindInputData = { +senderDeviceDescriptor: SenderDeviceDescriptor, +threadID: string, +rescindID?: string, +badge?: string, +platformDetails: PlatformDetails, }; async function createAndroidNotificationRescind( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: AndroidNotificationRescindInputData, devices: $ReadOnlyArray, -): Promise<$ReadOnlyArray> { +): Promise<{ + +targetedNotifications: $ReadOnlyArray, +}> { const { senderDeviceDescriptor, platformDetails, threadID, rescindID, badge, } = inputData; let notification = { data: { rescind: 'true', setUnreadStatus: 'true', threadID, }, }; if (rescindID && badge) { notification = { ...notification, data: { ...notification.data, badge, rescindID, }, }; } const shouldBeEncrypted = hasMinCodeVersion(platformDetails, { native: 233 }); if (!shouldBeEncrypted) { - return devices.map(({ deliveryID }) => ({ - notification, - deliveryID, - priority: 'normal', - })); + return { + targetedNotifications: devices.map(({ deliveryID }) => ({ + notification, + deliveryID, + priority: 'normal', + })), + }; } const notifications = await prepareEncryptedAndroidSilentNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, ); - return notifications.map(({ deliveryID, notification: notif }) => ({ - deliveryID, - notification: notif, - priority: 'normal', - })); + return { + targetedNotifications: notifications.map( + ({ deliveryID, notification: notif }) => ({ + deliveryID, + notification: notif, + priority: 'normal', + }), + ), + }; } type SenderDescriptorWithPlatformDetails = { +senderDeviceDescriptor: SenderDeviceDescriptor, +platformDetails: PlatformDetails, }; type AndroidBadgeOnlyNotificationInputData = $ReadOnly< | { ...SenderDescriptorWithPlatformDetails, +badge: string, } | { ...SenderDescriptorWithPlatformDetails, +threadID: string }, >; async function createAndroidBadgeOnlyNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: AndroidBadgeOnlyNotificationInputData, devices: $ReadOnlyArray, -): Promise<$ReadOnlyArray> { +): Promise<{ + +targetedNotifications: $ReadOnlyArray, +}> { const { senderDeviceDescriptor, platformDetails, badge, threadID } = inputData; let notificationData = { badgeOnly: '1' }; if (badge) { notificationData = { ...notificationData, badge, }; } else { invariant( threadID, 'Either badge or threadID must be present in badge only notif', ); notificationData = { ...notificationData, threadID, }; } const notification: AndroidBadgeOnlyNotification = { data: notificationData }; const shouldBeEncrypted = hasMinCodeVersion(platformDetails, { native: 222 }); if (!shouldBeEncrypted) { - return devices.map(({ deliveryID }) => ({ - notification, - deliveryID, - priority: 'normal', - })); + return { + targetedNotifications: devices.map(({ deliveryID }) => ({ + notification, + deliveryID, + priority: 'normal', + })), + }; } const notifications = await prepareEncryptedAndroidSilentNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, ); - return notifications.map( - ({ notification: notif, deliveryID, encryptionOrder }) => ({ - priority: 'normal', - notification: notif, - deliveryID, - encryptionOrder, - }), - ); + return { + targetedNotifications: notifications.map( + ({ notification: notif, deliveryID, encryptionOrder }) => ({ + priority: 'normal', + notification: notif, + deliveryID, + encryptionOrder, + }), + ), + }; } export { createAndroidVisualNotification, createAndroidBadgeOnlyNotification, createAndroidNotificationRescind, }; diff --git a/lib/push/apns-notif-creators.js b/lib/push/apns-notif-creators.js index eb48b8883..f31d71fdf 100644 --- a/lib/push/apns-notif-creators.js +++ b/lib/push/apns-notif-creators.js @@ -1,494 +1,577 @@ // @flow import invariant from 'invariant'; import t, { type TInterface } from 'tcomb'; import { type CommonNativeNotifInputData, commonNativeNotifInputDataValidator, } from './android-notif-creators.js'; import { prepareEncryptedAPNsVisualNotifications, prepareEncryptedAPNsSilentNotifications, + prepareLargeNotifData, + type LargeNotifData, } from './crypto.js'; import { getAPNsNotificationTopic } from '../shared/notif-utils.js'; import { hasMinCodeVersion } from '../shared/version-utils.js'; import type { PlatformDetails } from '../types/device-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type NotificationTargetDevice, type EncryptedNotifUtilsAPI, type TargetedAPNsNotification, type APNsVisualNotification, type APNsNotificationHeaders, type SenderDeviceDescriptor, } from '../types/notif-types.js'; import { tShape } from '../utils/validation-utils.js'; export const apnMaxNotificationPayloadByteSize = 4096; export type APNsNotifInputData = $ReadOnly<{ ...CommonNativeNotifInputData, +uniqueID: string, }>; export const apnsNotifInputDataValidator: TInterface = tShape({ ...commonNativeNotifInputDataValidator.meta.props, uniqueID: t.String, }); async function createAPNsVisualNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: APNsNotifInputData, devices: $ReadOnlyArray, -): Promise<$ReadOnlyArray> { + largeNotifToEncryptionResultPromises?: { + [string]: Promise, + }, +): Promise<{ + +targetedNotifications: $ReadOnlyArray, + +largeNotifData?: LargeNotifData, +}> { const { senderDeviceDescriptor, notifTexts, newRawMessageInfos, threadID, collapseKey, badgeOnly, unreadCount, platformDetails, uniqueID, } = inputData; const canDecryptNonCollapsibleTextIOSNotifs = hasMinCodeVersion( platformDetails, { native: 222 }, ); const isNonCollapsibleTextNotification = newRawMessageInfos.every( newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, ) && !collapseKey; const canDecryptAllIOSNotifs = hasMinCodeVersion(platformDetails, { native: 267, }); const canDecryptIOSNotif = platformDetails.platform === 'ios' && (canDecryptAllIOSNotifs || (isNonCollapsibleTextNotification && canDecryptNonCollapsibleTextIOSNotifs)); const canDecryptMacOSNotifs = platformDetails.platform === 'macos' && hasMinCodeVersion(platformDetails, { web: 47, majorDesktop: 9, }); let apsDictionary = { 'thread-id': threadID, }; if (unreadCount !== undefined && unreadCount !== null) { apsDictionary = { ...apsDictionary, badge: unreadCount, }; } const { merged, ...rest } = notifTexts; // We don't include alert's body on macos because we // handle displaying the notification ourselves and // we don't want macOS to display it automatically. if (!badgeOnly && platformDetails.platform !== 'macos') { apsDictionary = { ...apsDictionary, alert: merged, sound: 'default', }; } if (hasMinCodeVersion(platformDetails, { native: 198 })) { apsDictionary = { ...apsDictionary, 'mutable-content': 1, }; } let notificationPayload = { ...rest, id: uniqueID, threadID, }; let notificationHeaders: APNsNotificationHeaders = { 'apns-topic': getAPNsNotificationTopic(platformDetails), 'apns-id': uniqueID, 'apns-push-type': 'alert', }; if (collapseKey && (canDecryptAllIOSNotifs || canDecryptMacOSNotifs)) { notificationPayload = { ...notificationPayload, collapseID: collapseKey, }; } else if (collapseKey) { notificationHeaders = { ...notificationHeaders, 'apns-collapse-id': collapseKey, }; } const notification = { ...notificationPayload, headers: notificationHeaders, aps: apsDictionary, }; const messageInfos = JSON.stringify(newRawMessageInfos); const copyWithMessageInfos = { ...notification, messageInfos, }; const notificationSizeValidator = (notif: APNsVisualNotification) => { const { headers, ...notifSansHeaders } = notif; return ( encryptedNotifUtilsAPI.getNotifByteSize( JSON.stringify(notifSansHeaders), ) <= apnMaxNotificationPayloadByteSize ); }; const serializeAPNsNotif = (notif: APNsVisualNotification) => { const { headers, ...notifSansHeaders } = notif; return JSON.stringify(notifSansHeaders); }; const shouldBeEncrypted = canDecryptIOSNotif || canDecryptMacOSNotifs; if (!shouldBeEncrypted) { const notificationToSend = notificationSizeValidator(copyWithMessageInfos) ? copyWithMessageInfos : notification; - return devices.map(({ deliveryID }) => ({ + const targetedNotifications = devices.map(({ deliveryID }) => ({ notification: notificationToSend, deliveryID, })); + return { + targetedNotifications, + }; } // The `messageInfos` field in notification payload is // not used on MacOS so we can return early. if (platformDetails.platform === 'macos') { const macOSNotifsWithoutMessageInfos = await prepareEncryptedAPNsVisualNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, platformDetails.codeVersion, ); - return macOSNotifsWithoutMessageInfos.map( + const targetedNotifications = macOSNotifsWithoutMessageInfos.map( ({ notification: notif, deliveryID }) => ({ notification: notif, deliveryID, }), ); + return { targetedNotifications }; } const notifsWithMessageInfos = await prepareEncryptedAPNsVisualNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, copyWithMessageInfos, platformDetails.codeVersion, notificationSizeValidator, ); const devicesWithExcessiveSizeNoHolders = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) .map(({ cryptoID, deliveryID }) => ({ cryptoID, deliveryID, })); if (devicesWithExcessiveSizeNoHolders.length === 0) { - return notifsWithMessageInfos.map( + const targetedNotifications = notifsWithMessageInfos.map( ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }), ); + + return { + targetedNotifications, + }; } const canQueryBlobService = hasMinCodeVersion(platformDetails, { native: 331, }); - let blobHash, blobHolders, encryptionKey, blobUploadError; - if (canQueryBlobService) { + let blobHash, + blobHolders, + encryptionKey, + blobUploadError, + encryptedCopyWithMessageInfos; + + const copyWithMessageInfosBlob = serializeAPNsNotif(copyWithMessageInfos); + + if ( + canQueryBlobService && + largeNotifToEncryptionResultPromises && + largeNotifToEncryptionResultPromises[copyWithMessageInfosBlob] + ) { + const largeNotifData = + await largeNotifToEncryptionResultPromises[copyWithMessageInfosBlob]; + blobHash = largeNotifData.blobHash; + encryptionKey = largeNotifData.encryptionKey; + blobHolders = largeNotifData.blobHolders; + encryptedCopyWithMessageInfos = + largeNotifData.encryptedCopyWithMessageInfos; + } else if (canQueryBlobService && largeNotifToEncryptionResultPromises) { + largeNotifToEncryptionResultPromises[copyWithMessageInfosBlob] = + prepareLargeNotifData( + copyWithMessageInfosBlob, + devicesWithExcessiveSizeNoHolders.length, + encryptedNotifUtilsAPI, + ); + + const largeNotifData = + await largeNotifToEncryptionResultPromises[copyWithMessageInfosBlob]; + blobHash = largeNotifData.blobHash; + encryptionKey = largeNotifData.encryptionKey; + blobHolders = largeNotifData.blobHolders; + encryptedCopyWithMessageInfos = + largeNotifData.encryptedCopyWithMessageInfos; + } else if (canQueryBlobService) { ({ blobHash, blobHolders, encryptionKey, blobUploadError } = await encryptedNotifUtilsAPI.uploadLargeNotifPayload( - serializeAPNsNotif(copyWithMessageInfos), + copyWithMessageInfosBlob, devicesWithExcessiveSizeNoHolders.length, )); } if (blobUploadError) { console.warn( `Failed to upload payload of notification: ${uniqueID} ` + `due to error: ${blobUploadError}`, ); } let devicesWithExcessiveSize = devicesWithExcessiveSizeNoHolders; let notificationWithBlobMetadata = notification; if ( blobHash && encryptionKey && blobHolders && blobHolders.length === devicesWithExcessiveSize.length ) { notificationWithBlobMetadata = { ...notification, blobHash, encryptionKey, }; devicesWithExcessiveSize = blobHolders.map((holder, idx) => ({ ...devicesWithExcessiveSize[idx], blobHolder: holder, })); } const notifsWithoutMessageInfos = await prepareEncryptedAPNsVisualNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devicesWithExcessiveSize, notificationWithBlobMetadata, platformDetails.codeVersion, ); const targetedNotifsWithMessageInfos = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) .map( ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }), ); const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }), ); - - return [ + const targetedNotifications = [ ...targetedNotifsWithMessageInfos, ...targetedNotifsWithoutMessageInfos, ]; + if ( + !encryptedCopyWithMessageInfos || + !blobHolders || + !blobHash || + !encryptionKey + ) { + return { targetedNotifications }; + } + return { + targetedNotifications, + largeNotifData: { + blobHash, + blobHolders, + encryptionKey, + encryptedCopyWithMessageInfos, + }, + }; } type APNsNotificationRescindInputData = { +senderDeviceDescriptor: SenderDeviceDescriptor, +rescindID?: string, +badge?: number, +threadID: string, +platformDetails: PlatformDetails, }; async function createAPNsNotificationRescind( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: APNsNotificationRescindInputData, devices: $ReadOnlyArray, -): Promise<$ReadOnlyArray> { +): Promise<{ + +targetedNotifications: $ReadOnlyArray, +}> { const { badge, rescindID, threadID, platformDetails, senderDeviceDescriptor, } = inputData; const apnsTopic = getAPNsNotificationTopic(platformDetails); let notification; if ( rescindID && badge !== null && badge !== undefined && hasMinCodeVersion(platformDetails, { native: 198 }) ) { notification = { headers: { 'apns-topic': apnsTopic, 'apns-push-type': 'alert', }, aps: { 'mutable-content': 1, 'badge': badge, }, threadID, notificationId: rescindID, backgroundNotifType: 'CLEAR', setUnreadStatus: true, }; } else if (rescindID && badge !== null && badge !== undefined) { notification = { headers: { 'apns-topic': apnsTopic, 'apns-push-type': 'background', 'apns-priority': 5, }, aps: { 'mutable-content': 1, 'badge': badge, }, threadID, notificationId: rescindID, backgroundNotifType: 'CLEAR', setUnreadStatus: true, }; } else { notification = { headers: { 'apns-topic': apnsTopic, 'apns-push-type': 'alert', }, aps: { 'mutable-content': 1, }, threadID, backgroundNotifType: 'CLEAR', setUnreadStatus: true, }; } const shouldBeEncrypted = hasMinCodeVersion(platformDetails, { native: 233 }); if (!shouldBeEncrypted) { - return devices.map(({ deliveryID }) => ({ - notification, - deliveryID, - })); + return { + targetedNotifications: devices.map(({ deliveryID }) => ({ + notification, + deliveryID, + })), + }; } const notifications = await prepareEncryptedAPNsSilentNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, platformDetails.codeVersion, ); - return notifications.map(({ deliveryID, notification: notif }) => ({ - deliveryID, - notification: notif, - })); + return { + targetedNotifications: notifications.map( + ({ deliveryID, notification: notif }) => ({ + deliveryID, + notification: notif, + }), + ), + }; } type SenderDescriptorWithPlatformDetails = { +senderDeviceDescriptor: SenderDeviceDescriptor, +platformDetails: PlatformDetails, }; type APNsBadgeOnlyNotificationInputData = $ReadOnly< | { ...SenderDescriptorWithPlatformDetails, +badge: string, } | { ...SenderDescriptorWithPlatformDetails, +threadID: string }, >; async function createAPNsBadgeOnlyNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: APNsBadgeOnlyNotificationInputData, devices: $ReadOnlyArray, -): Promise<$ReadOnlyArray> { +): Promise<{ + +targetedNotifications: $ReadOnlyArray, + +largeNotifData?: LargeNotifData, +}> { const { senderDeviceDescriptor, platformDetails, threadID, badge } = inputData; const shouldBeEncrypted = hasMinCodeVersion(platformDetails, { native: 222, web: 47, majorDesktop: 9, }); const headers: APNsNotificationHeaders = { 'apns-topic': getAPNsNotificationTopic(platformDetails), 'apns-push-type': 'alert', }; let notification; if (shouldBeEncrypted && threadID) { notification = { headers, threadID, aps: { 'mutable-content': 1, }, }; } else if (shouldBeEncrypted && badge !== undefined && badge !== null) { notification = { headers, aps: { 'badge': badge, 'mutable-content': 1, }, }; } else { invariant( badge !== null && badge !== undefined, 'badge update must contain either badge count or threadID', ); notification = { headers, aps: { badge, }, }; } if (!shouldBeEncrypted) { - return devices.map(({ deliveryID }) => ({ - deliveryID, - notification, - })); + return { + targetedNotifications: devices.map(({ deliveryID }) => ({ + deliveryID, + notification, + })), + }; } const notifications = await prepareEncryptedAPNsSilentNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, platformDetails.codeVersion, ); - return notifications.map(({ deliveryID, notification: notif }) => ({ - deliveryID, - notification: notif, - })); + return { + targetedNotifications: notifications.map( + ({ deliveryID, notification: notif }) => ({ + deliveryID, + notification: notif, + }), + ), + }; } export { createAPNsBadgeOnlyNotification, createAPNsNotificationRescind, createAPNsVisualNotification, }; diff --git a/lib/push/crypto.js b/lib/push/crypto.js index 36844359b..a3ff0ffe8 100644 --- a/lib/push/crypto.js +++ b/lib/push/crypto.js @@ -1,620 +1,653 @@ // @flow import invariant from 'invariant'; +import uuid from 'uuid'; import type { PlainTextWebNotification, PlainTextWebNotificationPayload, WebNotification, PlainTextWNSNotification, WNSNotification, AndroidVisualNotification, AndroidVisualNotificationPayload, AndroidBadgeOnlyNotification, AndroidNotificationRescind, NotificationTargetDevice, SenderDeviceDescriptor, EncryptedNotifUtilsAPI, APNsVisualNotification, APNsNotificationRescind, APNsBadgeOnlyNotification, } from '../types/notif-types.js'; async function encryptAndroidNotificationPayload( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, unencryptedPayload: T, payloadSizeValidator?: ( | T | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string, +type: '1' | '0', }>, ) => boolean, ): Promise<{ +resultPayload: | T | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string, +type: '1' | '0', }>, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }> { try { const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); if (!unencryptedSerializedPayload) { return { resultPayload: unencryptedPayload, payloadSizeExceeded: payloadSizeValidator ? payloadSizeValidator(unencryptedPayload) : false, }; } let dbPersistCondition; if (payloadSizeValidator) { dbPersistCondition = (serializedPayload: string, type: '1' | '0') => payloadSizeValidator({ encryptedPayload: serializedPayload, type, ...senderDeviceDescriptor, }); } const { encryptedData: serializedPayload, sizeLimitViolated: dbPersistConditionViolated, encryptionOrder, } = await encryptedNotifUtilsAPI.encryptSerializedNotifPayload( cryptoID, unencryptedSerializedPayload, dbPersistCondition, ); return { resultPayload: { ...senderDeviceDescriptor, encryptedPayload: serializedPayload.body, type: serializedPayload.type ? '1' : '0', }, payloadSizeExceeded: !!dbPersistConditionViolated, encryptionOrder, }; } catch (e) { console.log('Notification encryption failed', e); const resultPayload = { encryptionFailed: '1', ...unencryptedPayload, }; return { resultPayload, payloadSizeExceeded: payloadSizeValidator ? payloadSizeValidator(resultPayload) : false, }; } } async function encryptAPNsVisualNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, notification: APNsVisualNotification, notificationSizeValidator?: APNsVisualNotification => boolean, codeVersion?: ?number, blobHolder?: ?string, ): Promise<{ +notification: APNsVisualNotification, +payloadSizeExceeded: boolean, +encryptedPayloadHash?: string, +encryptionOrder?: number, }> { const { id, headers, aps: { badge, alert, sound }, ...rest } = notification; invariant( !headers['apns-collapse-id'], `Collapse ID can't be directly stored in APNsVisualNotification object due ` + `to security reasons. Please put it in payload property`, ); let unencryptedPayload = { ...rest, merged: alert, badge, }; if (blobHolder) { unencryptedPayload = { ...unencryptedPayload, blobHolder }; } try { const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); let encryptedNotifAps = { 'mutable-content': 1, sound }; if (codeVersion && codeVersion >= 254 && codeVersion % 2 === 0) { encryptedNotifAps = { ...encryptedNotifAps, alert: { body: 'ENCRYPTED' }, }; } let dbPersistCondition; if (notificationSizeValidator) { dbPersistCondition = (encryptedPayload: string, type: '0' | '1') => notificationSizeValidator({ ...senderDeviceDescriptor, id, headers, encryptedPayload, type, aps: encryptedNotifAps, }); } const { encryptedData: serializedPayload, sizeLimitViolated: dbPersistConditionViolated, encryptionOrder, } = await encryptedNotifUtilsAPI.encryptSerializedNotifPayload( cryptoID, unencryptedSerializedPayload, dbPersistCondition, ); const encryptedPayloadHash = await encryptedNotifUtilsAPI.getEncryptedNotifHash( serializedPayload.body, ); return { notification: { ...senderDeviceDescriptor, id, headers, encryptedPayload: serializedPayload.body, type: serializedPayload.type ? '1' : '0', aps: encryptedNotifAps, }, payloadSizeExceeded: !!dbPersistConditionViolated, encryptedPayloadHash, encryptionOrder, }; } catch (e) { console.log('Notification encryption failed', e); const unencryptedNotification = { ...notification, encryptionFailed: '1' }; return { notification: unencryptedNotification, payloadSizeExceeded: notificationSizeValidator ? notificationSizeValidator(unencryptedNotification) : false, }; } } async function encryptAPNsSilentNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, notification: APNsNotificationRescind | APNsBadgeOnlyNotification, codeVersion?: ?number, ): Promise<{ +notification: APNsNotificationRescind | APNsBadgeOnlyNotification, +encryptedPayloadHash?: string, +encryptionOrder?: number, }> { const { headers, aps: { badge }, ...rest } = notification; let unencryptedPayload = { ...rest, }; if (badge !== null && badge !== undefined) { unencryptedPayload = { ...unencryptedPayload, badge, aps: {} }; } try { const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); let encryptedNotifAps = { 'mutable-content': 1 }; if (codeVersion && codeVersion >= 254 && codeVersion % 2 === 0) { encryptedNotifAps = { ...encryptedNotifAps, alert: { body: 'ENCRYPTED' }, }; } const { encryptedData: serializedPayload, encryptionOrder } = await encryptedNotifUtilsAPI.encryptSerializedNotifPayload( cryptoID, unencryptedSerializedPayload, ); const encryptedPayloadHash = await encryptedNotifUtilsAPI.getEncryptedNotifHash( serializedPayload.body, ); return { notification: { ...senderDeviceDescriptor, headers, encryptedPayload: serializedPayload.body, type: serializedPayload.type ? '1' : '0', aps: encryptedNotifAps, }, encryptedPayloadHash, encryptionOrder, }; } catch (e) { console.log('Notification encryption failed', e); const unencryptedNotification = { ...notification, encryptionFailed: '1' }; return { notification: unencryptedNotification, }; } } async function encryptAndroidVisualNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, cryptoID: string, notification: AndroidVisualNotification, notificationSizeValidator?: AndroidVisualNotification => boolean, blobHolder?: ?string, ): Promise<{ +notification: AndroidVisualNotification, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }> { const { id, ...rest } = notification.data; let unencryptedData = {}; if (id) { unencryptedData = { id }; } let unencryptedPayload = rest; if (blobHolder) { unencryptedPayload = { ...unencryptedPayload, blobHolder }; } let payloadSizeValidator; if (notificationSizeValidator) { payloadSizeValidator = ( payload: | AndroidVisualNotificationPayload | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string, +type: '0' | '1', }>, ) => { return notificationSizeValidator({ data: { ...unencryptedData, ...payload }, }); }; } const { resultPayload, payloadSizeExceeded, encryptionOrder } = await encryptAndroidNotificationPayload( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, unencryptedPayload, payloadSizeValidator, ); return { notification: { data: { ...unencryptedData, ...resultPayload, }, }, payloadSizeExceeded, encryptionOrder, }; } async function encryptAndroidSilentNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, notification: AndroidNotificationRescind | AndroidBadgeOnlyNotification, ): Promise { // We don't validate payload size for rescind // since they are expected to be small and // never exceed any FCM limit const { ...unencryptedPayload } = notification.data; const { resultPayload } = await encryptAndroidNotificationPayload( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, unencryptedPayload, ); if (resultPayload.encryptedPayload) { return { data: { ...resultPayload }, }; } if (resultPayload.rescind) { return { data: { ...resultPayload }, }; } return { data: { ...resultPayload, }, }; } async function encryptBasicPayload( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, basicPayload: T, ): Promise< | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string, +type: '1' | '0', +encryptionOrder?: number, }> | { ...T, +encryptionFailed: '1' }, > { const unencryptedSerializedPayload = JSON.stringify(basicPayload); if (!unencryptedSerializedPayload) { return { ...basicPayload, encryptionFailed: '1' }; } try { const { encryptedData: serializedPayload, encryptionOrder } = await encryptedNotifUtilsAPI.encryptSerializedNotifPayload( cryptoID, unencryptedSerializedPayload, ); return { ...senderDeviceDescriptor, encryptedPayload: serializedPayload.body, type: serializedPayload.type ? '1' : '0', encryptionOrder, }; } catch (e) { console.log('Notification encryption failed', e); return { ...basicPayload, encryptionFailed: '1', }; } } async function encryptWebNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, notification: PlainTextWebNotification, ): Promise<{ +notification: WebNotification, +encryptionOrder?: number }> { const { id, ...payloadSansId } = notification; const { encryptionOrder, ...encryptionResult } = await encryptBasicPayload( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, payloadSansId, ); return { notification: { id, ...encryptionResult }, encryptionOrder, }; } async function encryptWNSNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, notification: PlainTextWNSNotification, ): Promise<{ +notification: WNSNotification, +encryptionOrder?: number }> { const { encryptionOrder, ...encryptionResult } = await encryptBasicPayload( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, notification, ); return { notification: { ...encryptionResult }, encryptionOrder, }; } function prepareEncryptedAPNsVisualNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: APNsVisualNotification, codeVersion?: ?number, notificationSizeValidator?: APNsVisualNotification => boolean, ): Promise< $ReadOnlyArray<{ +cryptoID: string, +deliveryID: string, +notification: APNsVisualNotification, +payloadSizeExceeded: boolean, +encryptedPayloadHash?: string, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ cryptoID, deliveryID, blobHolder }) => { const notif = await encryptAPNsVisualNotification( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, notification, notificationSizeValidator, codeVersion, blobHolder, ); return { cryptoID, deliveryID, ...notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedAPNsSilentNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: APNsNotificationRescind | APNsBadgeOnlyNotification, codeVersion?: ?number, ): Promise< $ReadOnlyArray<{ +cryptoID: string, +deliveryID: string, +notification: APNsNotificationRescind | APNsBadgeOnlyNotification, }>, > { const notificationPromises = devices.map(async ({ deliveryID, cryptoID }) => { const { notification: notif } = await encryptAPNsSilentNotification( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, notification, codeVersion, ); return { cryptoID, deliveryID, notification: notif }; }); return Promise.all(notificationPromises); } function prepareEncryptedAndroidVisualNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: AndroidVisualNotification, notificationSizeValidator?: ( notification: AndroidVisualNotification, ) => boolean, ): Promise< $ReadOnlyArray<{ +cryptoID: string, +deliveryID: string, +notification: AndroidVisualNotification, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ deliveryID, cryptoID, blobHolder }) => { const notif = await encryptAndroidVisualNotification( encryptedNotifUtilsAPI, senderDeviceDescriptor, cryptoID, notification, notificationSizeValidator, blobHolder, ); return { deliveryID, cryptoID, ...notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedAndroidSilentNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: AndroidNotificationRescind | AndroidBadgeOnlyNotification, ): Promise< $ReadOnlyArray<{ +cryptoID: string, +deliveryID: string, +notification: AndroidNotificationRescind | AndroidBadgeOnlyNotification, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map(async ({ deliveryID, cryptoID }) => { const notif = await encryptAndroidSilentNotification( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, notification, ); return { deliveryID, cryptoID, notification: notif }; }); return Promise.all(notificationPromises); } function prepareEncryptedWebNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: PlainTextWebNotification, ): Promise< $ReadOnlyArray<{ +deliveryID: string, +notification: WebNotification, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map(async ({ deliveryID, cryptoID }) => { const notif = await encryptWebNotification( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, notification, ); return { ...notif, deliveryID }; }); return Promise.all(notificationPromises); } function prepareEncryptedWNSNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: PlainTextWNSNotification, ): Promise< $ReadOnlyArray<{ +deliveryID: string, +notification: WNSNotification, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map(async ({ deliveryID, cryptoID }) => { const notif = await encryptWNSNotification( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, notification, ); return { ...notif, deliveryID }; }); return Promise.all(notificationPromises); } +export type LargeNotifData = { + +blobHolders: $ReadOnlyArray, + +blobHash: string, + +encryptionKey: string, + +encryptedCopyWithMessageInfos: Uint8Array, +}; + +async function prepareLargeNotifData( + copyWithMessageInfos: string, + numberOfDevices: number, + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, +): Promise { + const encryptionKey = await encryptedNotifUtilsAPI.generateAESKey(); + + const blobHolders = Array.from({ length: numberOfDevices }, () => uuid.v4()); + const encryptedCopyWithMessageInfos = + await encryptedNotifUtilsAPI.encryptWithAESKey( + encryptionKey, + copyWithMessageInfos, + ); + const blobHash = await encryptedNotifUtilsAPI.getBlobHash( + encryptedCopyWithMessageInfos, + ); + return { + blobHolders, + blobHash, + encryptedCopyWithMessageInfos, + encryptionKey, + }; +} + export { prepareEncryptedAPNsVisualNotifications, prepareEncryptedAPNsSilentNotifications, prepareEncryptedAndroidVisualNotifications, prepareEncryptedAndroidSilentNotifications, prepareEncryptedWebNotifications, prepareEncryptedWNSNotifications, + prepareLargeNotifData, }; diff --git a/lib/push/send-hooks.react.js b/lib/push/send-hooks.react.js index e1878e97b..9edc946d9 100644 --- a/lib/push/send-hooks.react.js +++ b/lib/push/send-hooks.react.js @@ -1,236 +1,241 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import uuid from 'uuid'; import { preparePushNotifs, prepareOwnDevicesPushNotifs, type PerUserTargetedNotifications, } from './send-utils.js'; import { ENSCacheContext } from '../components/ens-cache-provider.react.js'; import { NeynarClientContext } from '../components/neynar-client-provider.react.js'; import { usePeerOlmSessionsCreatorContext } from '../components/peer-olm-session-creator-provider.react.js'; import { thickRawThreadInfosSelector } from '../selectors/thread-selectors.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { useTunnelbroker } from '../tunnelbroker/tunnelbroker-context.js'; import type { TargetedAPNsNotification, TargetedAndroidNotification, TargetedWebNotification, TargetedWNSNotification, NotificationsCreationData, } from '../types/notif-types.js'; import { deviceToTunnelbrokerMessageTypes } from '../types/tunnelbroker/messages.js'; import type { TunnelbrokerAPNsNotif, TunnelbrokerFCMNotif, TunnelbrokerWebPushNotif, TunnelbrokerWNSNotif, } from '../types/tunnelbroker/notif-types.js'; import { getConfig } from '../utils/config.js'; import { getMessageForException } from '../utils/errors.js'; import { useSelector } from '../utils/redux-utils.js'; function apnsNotifToTunnelbrokerAPNsNotif( targetedNotification: TargetedAPNsNotification, ): TunnelbrokerAPNsNotif { const { deliveryID: deviceID, notification: { headers, ...payload }, } = targetedNotification; const newHeaders = { ...headers, 'apns-push-type': 'Alert', }; return { type: deviceToTunnelbrokerMessageTypes.TUNNELBROKER_APNS_NOTIF, deviceID, headers: JSON.stringify(newHeaders), payload: JSON.stringify(payload), clientMessageID: uuid.v4(), }; } function androidNotifToTunnelbrokerFCMNotif( targetedNotification: TargetedAndroidNotification, ): TunnelbrokerFCMNotif { const { deliveryID: deviceID, notification: { data }, priority, } = targetedNotification; return { type: deviceToTunnelbrokerMessageTypes.TUNNELBROKER_FCM_NOTIF, deviceID, clientMessageID: uuid.v4(), data: JSON.stringify(data), priority: priority === 'normal' ? 'NORMAL' : 'HIGH', }; } function webNotifToTunnelbrokerWebPushNotif( targetedNotification: TargetedWebNotification, ): TunnelbrokerWebPushNotif { const { deliveryID: deviceID, notification } = targetedNotification; return { type: deviceToTunnelbrokerMessageTypes.TUNNELBROKER_WEB_PUSH_NOTIF, deviceID, clientMessageID: uuid.v4(), payload: JSON.stringify(notification), }; } function wnsNotifToTunnelbrokerWNSNofif( targetedNotification: TargetedWNSNotification, ): TunnelbrokerWNSNotif { const { deliveryID: deviceID, notification } = targetedNotification; return { type: deviceToTunnelbrokerMessageTypes.TUNNELBROKER_WNS_NOTIF, deviceID, clientMessageID: uuid.v4(), payload: JSON.stringify(notification), }; } function useSendPushNotifs(): ( notifCreationData: ?NotificationsCreationData, ) => Promise { const client = React.useContext(IdentityClientContext); invariant(client, 'Identity context should be set'); const { getAuthMetadata } = client; const rawMessageInfos = useSelector(state => state.messageStore.messages); const thickRawThreadInfos = useSelector(thickRawThreadInfosSelector); const auxUserInfos = useSelector(state => state.auxUserStore.auxUserInfos); const userInfos = useSelector(state => state.userStore.userInfos); const { getENSNames } = React.useContext(ENSCacheContext); const getFCNames = React.useContext(NeynarClientContext)?.getFCNames; const { createOlmSessionsWithUser: olmSessionCreator } = usePeerOlmSessionsCreatorContext(); const { sendNotif } = useTunnelbroker(); const { encryptedNotifUtilsAPI } = getConfig(); return React.useCallback( async (notifCreationData: ?NotificationsCreationData) => { if (!notifCreationData) { return; } const { deviceID, userID: senderUserID } = await getAuthMetadata(); if (!deviceID || !senderUserID) { return; } const senderDeviceDescriptor = { senderDeviceID: deviceID }; const senderInfo = { senderUserID, senderDeviceDescriptor, }; const { messageDatasWithMessageInfos, rescindData, badgeUpdateData } = notifCreationData; const pushNotifsPreparationInput = { encryptedNotifUtilsAPI, senderDeviceDescriptor, olmSessionCreator, messageInfos: rawMessageInfos, thickRawThreadInfos, auxUserInfos, messageDatasWithMessageInfos, userInfos, getENSNames, getFCNames, }; const ownDevicesPushNotifsPreparationInput = { encryptedNotifUtilsAPI, senderInfo, olmSessionCreator, auxUserInfos, rescindData, badgeUpdateData, }; const [preparedPushNotifs, preparedOwnDevicesPushNotifs] = await Promise.all([ preparePushNotifs(pushNotifsPreparationInput), prepareOwnDevicesPushNotifs(ownDevicesPushNotifsPreparationInput), ]); if (!preparedPushNotifs && !prepareOwnDevicesPushNotifs) { return; } - let allPreparedPushNotifs = preparedPushNotifs; + let allPreparedPushNotifs: ?PerUserTargetedNotifications = + preparedPushNotifs; + if (preparedOwnDevicesPushNotifs && senderUserID) { allPreparedPushNotifs = { ...allPreparedPushNotifs, - [senderUserID]: preparedOwnDevicesPushNotifs, + [senderUserID]: { + targetedNotifications: preparedOwnDevicesPushNotifs, + }, }; } const sendPromises = []; for (const userID in allPreparedPushNotifs) { - for (const notif of allPreparedPushNotifs[userID]) { + for (const notif of allPreparedPushNotifs[userID] + .targetedNotifications) { if (notif.targetedNotification.notification.encryptionFailed) { continue; } let tunnelbrokerNotif; if (notif.platform === 'ios' || notif.platform === 'macos') { tunnelbrokerNotif = apnsNotifToTunnelbrokerAPNsNotif( notif.targetedNotification, ); } else if (notif.platform === 'android') { tunnelbrokerNotif = androidNotifToTunnelbrokerFCMNotif( notif.targetedNotification, ); } else if (notif.platform === 'web') { tunnelbrokerNotif = webNotifToTunnelbrokerWebPushNotif( notif.targetedNotification, ); } else if (notif.platform === 'windows') { tunnelbrokerNotif = wnsNotifToTunnelbrokerWNSNofif( notif.targetedNotification, ); } else { continue; } sendPromises.push( (async () => { try { await sendNotif(tunnelbrokerNotif); } catch (e) { console.log( `Failed to send notification to device: ${ tunnelbrokerNotif.deviceID }. Details: ${getMessageForException(e) ?? ''}`, ); } })(), ); } } await Promise.all(sendPromises); }, [ getAuthMetadata, sendNotif, encryptedNotifUtilsAPI, olmSessionCreator, rawMessageInfos, thickRawThreadInfos, auxUserInfos, userInfos, getENSNames, getFCNames, ], ); } export { useSendPushNotifs }; diff --git a/lib/push/send-utils.js b/lib/push/send-utils.js index 6c2c1cc52..dfc3bf973 100644 --- a/lib/push/send-utils.js +++ b/lib/push/send-utils.js @@ -1,1198 +1,1315 @@ // @flow import _pickBy from 'lodash/fp/pickBy.js'; import uuidv4 from 'uuid/v4.js'; import { createAndroidVisualNotification, createAndroidBadgeOnlyNotification, createAndroidNotificationRescind, } from './android-notif-creators.js'; import { createAPNsVisualNotification, createAPNsBadgeOnlyNotification, createAPNsNotificationRescind, } from './apns-notif-creators.js'; +import type { LargeNotifData } from './crypto.js'; import { stringToVersionKey, getDevicesByPlatform, generateNotifUserInfoPromise, userAllowsNotif, } from './utils.js'; import { createWebNotification } from './web-notif-creators.js'; import { createWNSNotification } from './wns-notif-creators.js'; import { hasPermission } from '../permissions/minimally-encoded-thread-permissions.js'; import { createMessageInfo, shimUnsupportedRawMessageInfos, sortMessageInfoList, } from '../shared/message-utils.js'; import { pushTypes } from '../shared/messages/message-spec.js'; import { messageSpecs } from '../shared/messages/message-specs.js'; import { notifTextsForMessageInfo, getNotifCollapseKey, } from '../shared/notif-utils.js'; import { isMemberActive, threadInfoFromRawThreadInfo, } from '../shared/thread-utils.js'; import { hasMinCodeVersion } from '../shared/version-utils.js'; import type { AuxUserInfos } from '../types/aux-user-types.js'; import type { PlatformDetails, Platform } from '../types/device-types.js'; import { identityDeviceTypeToPlatform, type IdentityPlatformDetails, } from '../types/identity-service-types.js'; import { type MessageData, type RawMessageInfo, } from '../types/message-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { ResolvedNotifTexts, NotificationTargetDevice, TargetedNotificationWithPlatform, SenderDeviceDescriptor, EncryptedNotifUtilsAPI, } from '../types/notif-types.js'; import type { ThreadSubscription } from '../types/subscription-types.js'; import type { ThickRawThreadInfos } from '../types/thread-types.js'; import type { UserInfos } from '../types/user-types.js'; import { getConfig } from '../utils/config.js'; import type { DeviceSessionCreationRequest } from '../utils/crypto-utils.js'; import { type GetENSNames } from '../utils/ens-helpers.js'; import { type GetFCNames } from '../utils/farcaster-helpers.js'; import { values } from '../utils/objects.js'; import { promiseAll } from '../utils/promises.js'; export type Device = { +platformDetails: PlatformDetails, +deliveryID: string, +cryptoID: string, }; export type ThreadSubscriptionWithRole = $ReadOnly<{ ...ThreadSubscription, +role: ?string, }>; export type PushUserInfo = { +devices: $ReadOnlyArray, +messageInfos: RawMessageInfo[], +messageDatas: MessageData[], +subscriptions?: { +[threadID: string]: ThreadSubscriptionWithRole, }, }; export type PushInfo = { +[userID: string]: PushUserInfo }; export type CollapsableNotifInfo = { collapseKey: ?string, existingMessageInfos: RawMessageInfo[], newMessageInfos: RawMessageInfo[], }; export type FetchCollapsableNotifsResult = { +[userID: string]: $ReadOnlyArray, }; function identityPlatformDetailsToPlatformDetails( identityPlatformDetails: IdentityPlatformDetails, ): PlatformDetails { const { deviceType, ...rest } = identityPlatformDetails; return { ...rest, platform: identityDeviceTypeToPlatform[deviceType], }; } async function getPushUserInfo( messageInfos: { +[id: string]: RawMessageInfo }, thickRawThreadInfos: ThickRawThreadInfos, auxUserInfos: AuxUserInfos, messageDataWithMessageInfos: ?$ReadOnlyArray<{ +messageData: MessageData, +rawMessageInfo: RawMessageInfo, }>, ): Promise<{ +pushInfos: ?PushInfo, +rescindInfos: ?PushInfo, }> { if (!messageDataWithMessageInfos) { return { pushInfos: null, rescindInfos: null }; } const threadsToMessageIndices: Map = new Map(); const newMessageInfos: RawMessageInfo[] = []; const messageDatas: MessageData[] = []; let nextNewMessageIndex = 0; for (const messageDataWithInfo of messageDataWithMessageInfos) { const { messageData, rawMessageInfo } = messageDataWithInfo; const threadID = messageData.threadID; let messageIndices = threadsToMessageIndices.get(threadID); if (!messageIndices) { messageIndices = []; threadsToMessageIndices.set(threadID, messageIndices); } const newMessageIndex = nextNewMessageIndex++; messageIndices.push(newMessageIndex); messageDatas.push(messageData); newMessageInfos.push(rawMessageInfo); } const pushUserThreadInfos: { [userID: string]: { devices: $ReadOnlyArray, threadsWithSubscriptions: { [threadID: string]: ThreadSubscriptionWithRole, }, }, } = {}; for (const threadID of threadsToMessageIndices.keys()) { const threadInfo = thickRawThreadInfos[threadID]; for (const memberInfo of threadInfo.members) { if ( !isMemberActive(memberInfo) || !hasPermission(memberInfo.permissions, 'visible') ) { continue; } if (pushUserThreadInfos[memberInfo.id]) { pushUserThreadInfos[memberInfo.id].threadsWithSubscriptions[threadID] = { ...memberInfo.subscription, role: memberInfo.role }; continue; } const devicesPlatformDetails = auxUserInfos[memberInfo.id].devicesPlatformDetails; if (!devicesPlatformDetails) { continue; } const devices = Object.entries(devicesPlatformDetails).map( ([deviceID, identityPlatformDetails]) => ({ platformDetails: identityPlatformDetailsToPlatformDetails( identityPlatformDetails, ), deliveryID: deviceID, cryptoID: deviceID, }), ); pushUserThreadInfos[memberInfo.id] = { devices, threadsWithSubscriptions: { [threadID]: { ...memberInfo.subscription, role: memberInfo.role }, }, }; } } const userPushInfoPromises: { [string]: Promise } = {}; const userRescindInfoPromises: { [string]: Promise } = {}; for (const userID in pushUserThreadInfos) { const pushUserThreadInfo = pushUserThreadInfos[userID]; userPushInfoPromises[userID] = (async () => { const pushInfosWithoutSubscriptions = await generateNotifUserInfoPromise({ pushType: pushTypes.NOTIF, devices: pushUserThreadInfo.devices, newMessageInfos, messageDatas, threadsToMessageIndices, threadIDs: Object.keys(pushUserThreadInfo.threadsWithSubscriptions), userNotMemberOfSubthreads: new Set(), fetchMessageInfoByID: (messageID: string) => (async () => messageInfos[messageID])(), userID, }); if (!pushInfosWithoutSubscriptions) { return null; } return { ...pushInfosWithoutSubscriptions, subscriptions: pushUserThreadInfo.threadsWithSubscriptions, }; })(); userRescindInfoPromises[userID] = (async () => { const pushInfosWithoutSubscriptions = await generateNotifUserInfoPromise({ pushType: pushTypes.RESCIND, devices: pushUserThreadInfo.devices, newMessageInfos, messageDatas, threadsToMessageIndices, threadIDs: Object.keys(pushUserThreadInfo.threadsWithSubscriptions), userNotMemberOfSubthreads: new Set(), fetchMessageInfoByID: (messageID: string) => (async () => messageInfos[messageID])(), userID, }); if (!pushInfosWithoutSubscriptions) { return null; } return { ...pushInfosWithoutSubscriptions, subscriptions: pushUserThreadInfo.threadsWithSubscriptions, }; })(); } const [pushInfo, rescindInfo] = await Promise.all([ promiseAll(userPushInfoPromises), promiseAll(userRescindInfoPromises), ]); return { pushInfos: _pickBy(Boolean)(pushInfo), rescindInfos: _pickBy(Boolean)(rescindInfo), }; } type SenderInfo = { +senderUserID: string, +senderDeviceDescriptor: SenderDeviceDescriptor, }; type OwnDevicesPushInfo = { +devices: $ReadOnlyArray, }; function getOwnDevicesPushInfo( senderInfo: SenderInfo, auxUserInfos: AuxUserInfos, ): ?OwnDevicesPushInfo { const { senderUserID, senderDeviceDescriptor: { senderDeviceID }, } = senderInfo; if (!senderDeviceID) { return null; } const senderDevicesWithPlatformDetails = auxUserInfos[senderUserID].devicesPlatformDetails; if (!senderDevicesWithPlatformDetails) { return null; } const devices = Object.entries(senderDevicesWithPlatformDetails) .filter(([deviceID]) => deviceID !== senderDeviceID) .map(([deviceID, identityPlatformDetails]) => ({ platformDetails: identityPlatformDetailsToPlatformDetails( identityPlatformDetails, ), deliveryID: deviceID, cryptoID: deviceID, })); return { devices }; } function pushInfoToCollapsableNotifInfo(pushInfo: PushInfo): { +usersToCollapseKeysToInfo: { +[string]: { +[string]: CollapsableNotifInfo }, }, +usersToCollapsableNotifInfo: { +[string]: $ReadOnlyArray, }, } { const usersToCollapseKeysToInfo: { [string]: { [string]: CollapsableNotifInfo }, } = {}; const usersToCollapsableNotifInfo: { [string]: Array } = {}; for (const userID in pushInfo) { usersToCollapseKeysToInfo[userID] = {}; usersToCollapsableNotifInfo[userID] = []; for (let i = 0; i < pushInfo[userID].messageInfos.length; i++) { const rawMessageInfo = pushInfo[userID].messageInfos[i]; const messageData = pushInfo[userID].messageDatas[i]; const collapseKey = getNotifCollapseKey(rawMessageInfo, messageData); if (!collapseKey) { const collapsableNotifInfo: CollapsableNotifInfo = { collapseKey, existingMessageInfos: [], newMessageInfos: [rawMessageInfo], }; usersToCollapsableNotifInfo[userID].push(collapsableNotifInfo); continue; } if (!usersToCollapseKeysToInfo[userID][collapseKey]) { usersToCollapseKeysToInfo[userID][collapseKey] = ({ collapseKey, existingMessageInfos: [], newMessageInfos: [], }: CollapsableNotifInfo); } usersToCollapseKeysToInfo[userID][collapseKey].newMessageInfos.push( rawMessageInfo, ); } } return { usersToCollapseKeysToInfo, usersToCollapsableNotifInfo, }; } function mergeUserToCollapsableInfo( usersToCollapseKeysToInfo: { +[string]: { +[string]: CollapsableNotifInfo }, }, usersToCollapsableNotifInfo: { +[string]: $ReadOnlyArray, }, ): { +[string]: $ReadOnlyArray } { const mergedUsersToCollapsableInfo: { [string]: Array, } = {}; for (const userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; mergedUsersToCollapsableInfo[userID] = [ ...usersToCollapsableNotifInfo[userID], ]; for (const collapseKey in collapseKeysToInfo) { const info = collapseKeysToInfo[collapseKey]; mergedUsersToCollapsableInfo[userID].push({ collapseKey: info.collapseKey, existingMessageInfos: sortMessageInfoList(info.existingMessageInfos), newMessageInfos: sortMessageInfoList(info.newMessageInfos), }); } } return mergedUsersToCollapsableInfo; } async function buildNotifText( rawMessageInfos: $ReadOnlyArray, userID: string, threadInfos: { +[id: string]: ThreadInfo }, subscriptions: ?{ +[threadID: string]: ThreadSubscriptionWithRole }, userInfos: UserInfos, getENSNames: ?GetENSNames, getFCNames: ?GetFCNames, ): Promise, +badgeOnly: boolean, }> { if (!subscriptions) { return null; } const hydrateMessageInfo = (rawMessageInfo: RawMessageInfo) => createMessageInfo(rawMessageInfo, userID, userInfos, threadInfos); const newMessageInfos = []; const newRawMessageInfos = []; for (const rawMessageInfo of rawMessageInfos) { const newMessageInfo = hydrateMessageInfo(rawMessageInfo); if (newMessageInfo) { newMessageInfos.push(newMessageInfo); newRawMessageInfos.push(rawMessageInfo); } } if (newMessageInfos.length === 0) { return null; } const [{ threadID }] = newMessageInfos; const threadInfo = threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; const subscription = subscriptions[threadID]; if (!subscription) { return null; } const username = userInfos[userID] && userInfos[userID].username; const { notifAllowed, badgeOnly } = await userAllowsNotif({ subscription, userID, newMessageInfos, userInfos, username, getENSNames, }); if (!notifAllowed) { return null; } const notifTargetUserInfo = { id: userID, username }; const notifTexts = await notifTextsForMessageInfo( newMessageInfos, threadInfo, parentThreadInfo, notifTargetUserInfo, getENSNames, getFCNames, ); if (!notifTexts) { return null; } return { notifTexts, newRawMessageInfos, badgeOnly }; } type BuildNotifsForPlatformInput< PlatformType: Platform, NotifCreatorinputBase, TargetedNotificationType, NotifCreatorInput: { +platformDetails: PlatformDetails, ... }, > = { +platform: PlatformType, +encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, +notifCreatorCallback: ( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, input: NotifCreatorInput, devices: $ReadOnlyArray, - ) => Promise<$ReadOnlyArray>, + largeNotifToEncryptionResultPromises?: { + [string]: Promise, + }, + ) => Promise<{ + +targetedNotifications: $ReadOnlyArray, + +largeNotifData?: LargeNotifData, + }>, +notifCreatorInputBase: NotifCreatorinputBase, +transformInputBase: ( inputBase: NotifCreatorinputBase, platformDetails: PlatformDetails, ) => NotifCreatorInput, +versionToDevices: $ReadOnlyMap< string, $ReadOnlyArray, >, }; async function buildNotifsForPlatform< PlatformType: Platform, NotifCreatorinputBase, TargetedNotificationType, NotifCreatorInput: { +platformDetails: PlatformDetails, ... }, >( input: BuildNotifsForPlatformInput< PlatformType, NotifCreatorinputBase, TargetedNotificationType, NotifCreatorInput, >, -): Promise< - $ReadOnlyArray<{ + largeNotifToEncryptionResultPromises?: { + [string]: Promise, + }, +): Promise<{ + +targetedNotificationsWithPlatform: $ReadOnlyArray<{ +platform: PlatformType, +targetedNotification: TargetedNotificationType, }>, -> { + +largeNotifDataArray?: $ReadOnlyArray, +}> { const { encryptedNotifUtilsAPI, versionToDevices, notifCreatorCallback, notifCreatorInputBase, platform, transformInputBase, } = input; const promises: Array< - Promise< - $ReadOnlyArray<{ + Promise<{ + +targetedNotificationsWithPlatform: $ReadOnlyArray<{ +platform: PlatformType, +targetedNotification: TargetedNotificationType, }>, - >, + +largeNotifData?: LargeNotifData, + }>, > = []; for (const [versionKey, devices] of versionToDevices) { const { codeVersion, stateVersion, majorDesktopVersion } = stringToVersionKey(versionKey); const platformDetails = { platform, codeVersion, stateVersion, majorDesktopVersion, }; const inputData = transformInputBase( notifCreatorInputBase, platformDetails, ); promises.push( (async () => { - return ( - await notifCreatorCallback(encryptedNotifUtilsAPI, inputData, devices) - ).map(targetedNotification => ({ - platform, - targetedNotification, - })); + const { targetedNotifications, largeNotifData } = + await notifCreatorCallback( + encryptedNotifUtilsAPI, + inputData, + devices, + largeNotifToEncryptionResultPromises, + ); + const targetedNotificationsWithPlatform = targetedNotifications.map( + targetedNotification => ({ + platform, + targetedNotification, + }), + ); + return { targetedNotificationsWithPlatform, largeNotifData }; })(), ); } - return (await Promise.all(promises)).flat(); + const results = await Promise.all(promises); + const targetedNotifsWithPlatform = results + .map( + ({ targetedNotificationsWithPlatform }) => + targetedNotificationsWithPlatform, + ) + .flat(); + + const largeNotifDataArray = results + .map(({ largeNotifData }) => largeNotifData) + .filter(Boolean); + + return { + largeNotifDataArray, + targetedNotificationsWithPlatform: targetedNotifsWithPlatform, + }; } type BuildNotifsForUserDevicesInputData = { +encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, +senderDeviceDescriptor: SenderDeviceDescriptor, +rawMessageInfos: $ReadOnlyArray, +userID: string, +threadInfos: { +[id: string]: ThreadInfo }, +subscriptions: ?{ +[threadID: string]: ThreadSubscriptionWithRole }, +userInfos: UserInfos, +getENSNames: ?GetENSNames, +getFCNames: ?GetFCNames, +devicesByPlatform: $ReadOnlyMap< Platform, $ReadOnlyMap>, >, }; async function buildNotifsForUserDevices( inputData: BuildNotifsForUserDevicesInputData, -): Promise> { + largeNotifToEncryptionResultPromises: { + [string]: Promise, + }, +): Promise, + +largeNotifDataArray?: $ReadOnlyArray, +}> { const { encryptedNotifUtilsAPI, senderDeviceDescriptor, rawMessageInfos, userID, threadInfos, subscriptions, userInfos, getENSNames, getFCNames, devicesByPlatform, } = inputData; const notifTextWithNewRawMessageInfos = await buildNotifText( rawMessageInfos, userID, threadInfos, subscriptions, userInfos, getENSNames, getFCNames, ); if (!notifTextWithNewRawMessageInfos) { return null; } const { notifTexts, newRawMessageInfos, badgeOnly } = notifTextWithNewRawMessageInfos; const [{ threadID }] = newRawMessageInfos; const promises: Array< - Promise<$ReadOnlyArray>, + Promise<{ + +targetedNotificationsWithPlatform: $ReadOnlyArray, + +largeNotifDataArray?: $ReadOnlyArray, + }>, > = []; const iosVersionToDevices = devicesByPlatform.get('ios'); if (iosVersionToDevices) { promises.push( - buildNotifsForPlatform({ - platform: 'ios', - encryptedNotifUtilsAPI, - notifCreatorCallback: createAPNsVisualNotification, - transformInputBase: (inputBase, platformDetails) => ({ - ...inputBase, - newRawMessageInfos: shimUnsupportedRawMessageInfos( - newRawMessageInfos, + buildNotifsForPlatform( + { + platform: 'ios', + encryptedNotifUtilsAPI, + notifCreatorCallback: createAPNsVisualNotification, + transformInputBase: (inputBase, platformDetails) => ({ + ...inputBase, + newRawMessageInfos: shimUnsupportedRawMessageInfos( + newRawMessageInfos, + platformDetails, + ), platformDetails, - ), - platformDetails, - }), - notifCreatorInputBase: { - senderDeviceDescriptor, - notifTexts, - threadID, - collapseKey: undefined, - badgeOnly, - uniqueID: uuidv4(), + }), + notifCreatorInputBase: { + senderDeviceDescriptor, + notifTexts, + threadID, + collapseKey: undefined, + badgeOnly, + uniqueID: uuidv4(), + }, + versionToDevices: iosVersionToDevices, }, - versionToDevices: iosVersionToDevices, - }), + largeNotifToEncryptionResultPromises, + ), ); } const androidVersionToDevices = devicesByPlatform.get('android'); if (androidVersionToDevices) { promises.push( - buildNotifsForPlatform({ - platform: 'android', - encryptedNotifUtilsAPI, - notifCreatorCallback: createAndroidVisualNotification, - transformInputBase: (inputBase, platformDetails) => ({ - ...inputBase, - newRawMessageInfos: shimUnsupportedRawMessageInfos( - newRawMessageInfos, + buildNotifsForPlatform( + { + platform: 'android', + encryptedNotifUtilsAPI, + notifCreatorCallback: createAndroidVisualNotification, + transformInputBase: (inputBase, platformDetails) => ({ + ...inputBase, + newRawMessageInfos: shimUnsupportedRawMessageInfos( + newRawMessageInfos, + platformDetails, + ), platformDetails, - ), - platformDetails, - }), - notifCreatorInputBase: { - senderDeviceDescriptor, - notifTexts, - threadID, - collapseKey: undefined, - badgeOnly, - notifID: uuidv4(), + }), + notifCreatorInputBase: { + senderDeviceDescriptor, + notifTexts, + threadID, + collapseKey: undefined, + badgeOnly, + notifID: uuidv4(), + }, + versionToDevices: androidVersionToDevices, }, - versionToDevices: androidVersionToDevices, - }), + largeNotifToEncryptionResultPromises, + ), ); } const macosVersionToDevices = devicesByPlatform.get('macos'); if (macosVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'macos', encryptedNotifUtilsAPI, notifCreatorCallback: createAPNsVisualNotification, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, newRawMessageInfos: shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ), platformDetails, }), notifCreatorInputBase: { senderDeviceDescriptor, notifTexts, threadID, collapseKey: undefined, badgeOnly, uniqueID: uuidv4(), }, versionToDevices: macosVersionToDevices, }), ); } const windowsVersionToDevices = devicesByPlatform.get('windows'); if (windowsVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'windows', encryptedNotifUtilsAPI, notifCreatorCallback: createWNSNotification, notifCreatorInputBase: { senderDeviceDescriptor, notifTexts, threadID, }, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, platformDetails, }), versionToDevices: windowsVersionToDevices, }), ); } const webVersionToDevices = devicesByPlatform.get('web'); if (webVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'web', encryptedNotifUtilsAPI, notifCreatorCallback: createWebNotification, notifCreatorInputBase: { senderDeviceDescriptor, notifTexts, threadID, id: uuidv4(), }, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, platformDetails, }), versionToDevices: webVersionToDevices, }), ); } - return (await Promise.all(promises)).flat(); + const results = await Promise.all(promises); + const targetedNotifsWithPlatform = results + .map( + ({ targetedNotificationsWithPlatform }) => + targetedNotificationsWithPlatform, + ) + .flat(); + + const largeNotifDataArray = results + .map(({ largeNotifDataArray: array }) => array) + .filter(Boolean) + .flat(); + + return { + largeNotifDataArray, + targetedNotificationsWithPlatform: targetedNotifsWithPlatform, + }; } async function buildRescindsForOwnDevices( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devicesByPlatform: $ReadOnlyMap< Platform, $ReadOnlyMap>, >, rescindData: { +threadID: string }, ): Promise<$ReadOnlyArray> { const { threadID } = rescindData; const promises: Array< - Promise<$ReadOnlyArray>, + Promise<{ + +targetedNotificationsWithPlatform: $ReadOnlyArray, + ... + }>, > = []; const iosVersionToDevices = devicesByPlatform.get('ios'); if (iosVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'ios', encryptedNotifUtilsAPI, notifCreatorCallback: createAPNsNotificationRescind, notifCreatorInputBase: { senderDeviceDescriptor, threadID, }, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, platformDetails, }), versionToDevices: iosVersionToDevices, }), ); } const androidVersionToDevices = devicesByPlatform.get('android'); if (androidVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'android', encryptedNotifUtilsAPI, notifCreatorCallback: createAndroidNotificationRescind, notifCreatorInputBase: { senderDeviceDescriptor, threadID, }, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, platformDetails, }), versionToDevices: androidVersionToDevices, }), ); } - return (await Promise.all(promises)).flat(); + + const results = await Promise.all(promises); + const targetedNotifsWithPlatform = results + .map( + ({ targetedNotificationsWithPlatform }) => + targetedNotificationsWithPlatform, + ) + .flat(); + return targetedNotifsWithPlatform; } async function buildBadgeUpdatesForOwnDevices( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devicesByPlatform: $ReadOnlyMap< Platform, $ReadOnlyMap>, >, badgeUpdateData: { +threadID: string }, ): Promise<$ReadOnlyArray> { const { threadID } = badgeUpdateData; const promises: Array< - Promise<$ReadOnlyArray>, + Promise<{ + +targetedNotificationsWithPlatform: $ReadOnlyArray, + ... + }>, > = []; const iosVersionToDevices = devicesByPlatform.get('ios'); if (iosVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'ios', encryptedNotifUtilsAPI, notifCreatorCallback: createAPNsBadgeOnlyNotification, notifCreatorInputBase: { senderDeviceDescriptor, threadID, }, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, platformDetails, }), versionToDevices: iosVersionToDevices, }), ); } const androidVersionToDevices = devicesByPlatform.get('android'); if (androidVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'android', encryptedNotifUtilsAPI, notifCreatorCallback: createAndroidBadgeOnlyNotification, notifCreatorInputBase: { senderDeviceDescriptor, threadID, }, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, platformDetails, }), versionToDevices: androidVersionToDevices, }), ); } const macosVersionToDevices = devicesByPlatform.get('macos'); if (macosVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'macos', encryptedNotifUtilsAPI, notifCreatorCallback: createAPNsBadgeOnlyNotification, notifCreatorInputBase: { senderDeviceDescriptor, threadID, }, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, platformDetails, }), versionToDevices: macosVersionToDevices, }), ); } - return (await Promise.all(promises)).flat(); + + const results = await Promise.all(promises); + const targetedNotifsWithPlatform = results + .map( + ({ targetedNotificationsWithPlatform }) => + targetedNotificationsWithPlatform, + ) + .flat(); + return targetedNotifsWithPlatform; } export type PerUserTargetedNotifications = { - +[userID: string]: $ReadOnlyArray, + +[userID: string]: { + +targetedNotifications: $ReadOnlyArray, + +largeNotifDataArray?: $ReadOnlyArray, + }, }; type BuildNotifsFromPushInfoInputData = { +encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, +senderDeviceDescriptor: SenderDeviceDescriptor, +pushInfo: PushInfo, +thickRawThreadInfos: ThickRawThreadInfos, +userInfos: UserInfos, +getENSNames: ?GetENSNames, +getFCNames: ?GetFCNames, }; async function buildNotifsFromPushInfo( inputData: BuildNotifsFromPushInfoInputData, ): Promise { const { encryptedNotifUtilsAPI, senderDeviceDescriptor, pushInfo, thickRawThreadInfos, userInfos, getENSNames, getFCNames, } = inputData; const threadIDs = new Set(); for (const userID in pushInfo) { for (const rawMessageInfo of pushInfo[userID].messageInfos) { const threadID = rawMessageInfo.threadID; threadIDs.add(threadID); const messageSpec = messageSpecs[rawMessageInfo.type]; if (messageSpec.threadIDs) { for (const id of messageSpec.threadIDs(rawMessageInfo)) { threadIDs.add(id); } } } } const perUserBuildNotifsResultPromises: { - [userID: string]: Promise<$ReadOnlyArray>, + [userID: string]: Promise<{ + +targetedNotifications: $ReadOnlyArray, + +largeNotifDataArray?: $ReadOnlyArray, + }>, } = {}; const { usersToCollapsableNotifInfo, usersToCollapseKeysToInfo } = pushInfoToCollapsableNotifInfo(pushInfo); const mergedUsersToCollapsableInfo = mergeUserToCollapsableInfo( usersToCollapseKeysToInfo, usersToCollapsableNotifInfo, ); + const largeNotifToEncryptionResultPromises: { + [string]: Promise, + } = {}; + for (const userID in mergedUsersToCollapsableInfo) { const threadInfos = Object.fromEntries( [...threadIDs].map(threadID => [ threadID, threadInfoFromRawThreadInfo( thickRawThreadInfos[threadID], userID, userInfos, ), ]), ); const devicesByPlatform = getDevicesByPlatform(pushInfo[userID].devices); const singleNotificationPromises = []; for (const notifInfo of mergedUsersToCollapsableInfo[userID]) { singleNotificationPromises.push( // We always pass one element array here // because coalescing is not supported for // notifications generated on the client - buildNotifsForUserDevices({ - encryptedNotifUtilsAPI, - senderDeviceDescriptor, - rawMessageInfos: notifInfo.newMessageInfos, - userID, - threadInfos, - subscriptions: pushInfo[userID].subscriptions, - userInfos, - getENSNames, - getFCNames, - devicesByPlatform, - }), + buildNotifsForUserDevices( + { + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + rawMessageInfos: notifInfo.newMessageInfos, + userID, + threadInfos, + subscriptions: pushInfo[userID].subscriptions, + userInfos, + getENSNames, + getFCNames, + devicesByPlatform, + }, + largeNotifToEncryptionResultPromises, + ), ); } perUserBuildNotifsResultPromises[userID] = (async () => { - const singleNotificationResults = await Promise.all( - singleNotificationPromises, - ); - return singleNotificationResults.filter(Boolean).flat(); + const singleNotificationResults = ( + await Promise.all(singleNotificationPromises) + ).filter(Boolean); + + const targetedNotifsWithPlatform = singleNotificationResults + .map( + ({ targetedNotificationsWithPlatform }) => + targetedNotificationsWithPlatform, + ) + .flat(); + + const largeNotifDataArray = singleNotificationResults + .map(({ largeNotifDataArray: array }) => array) + .filter(Boolean) + .flat(); + console.log(largeNotifDataArray); + return { + targetedNotifications: targetedNotifsWithPlatform, + largeNotifDataArray, + }; })(); } return promiseAll(perUserBuildNotifsResultPromises); } async function createOlmSessionWithDevices( userDevices: { +[userID: string]: $ReadOnlyArray, }, olmSessionCreator: ( userID: string, devices: $ReadOnlyArray, ) => Promise, ): Promise { const { initializeCryptoAccount, isNotificationsSessionInitializedWithDevices, } = getConfig().olmAPI; await initializeCryptoAccount(); const deviceIDsToSessionPresence = await isNotificationsSessionInitializedWithDevices( values(userDevices).flat(), ); const olmSessionCreationPromises = []; for (const userID in userDevices) { const devices = userDevices[userID] .filter(deviceID => !deviceIDsToSessionPresence[deviceID]) .map(deviceID => ({ deviceID, })); olmSessionCreationPromises.push(olmSessionCreator(userID, devices)); } try { // The below is equvialent to // Promise.allSettled(olmSessionCreationPromises), which appears to be // undefined in Android (at least on debug builds) as of Sept 2024 await Promise.all( olmSessionCreationPromises.map(async promise => { try { const result = await promise; return ({ status: 'fulfilled', value: result, }: $SettledPromiseResult); } catch (e) { return ({ status: 'rejected', reason: e, }: $SettledPromiseResult); } }), ); } catch (e) { // session creation may fail for some devices // but we should still pursue notification // delivery for others console.log('Olm session creation failed', e); } } function filterDevicesSupportingDMNotifs< T: { +devices: $ReadOnlyArray, ... }, >(devicesContainer: T): T { return { ...devicesContainer, devices: devicesContainer.devices.filter(({ platformDetails }) => hasMinCodeVersion(platformDetails, { native: 393, web: 115, majorDesktop: 14, }), ), }; } function filterDevicesSupportingDMNotifsForUsers< T: { +devices: $ReadOnlyArray, ... }, >(userToDevicesContainer: { +[userID: string]: T }): { +[userID: string]: T } { const result: { [userID: string]: T } = {}; for (const userID in userToDevicesContainer) { const devicesContainer = userToDevicesContainer[userID]; const filteredDevicesContainer = filterDevicesSupportingDMNotifs(devicesContainer); if (filteredDevicesContainer.devices.length === 0) { continue; } result[userID] = filteredDevicesContainer; } return result; } type PreparePushNotifsInputData = { +encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, +senderDeviceDescriptor: SenderDeviceDescriptor, +olmSessionCreator: ( userID: string, devices: $ReadOnlyArray, ) => Promise, +messageInfos: { +[id: string]: RawMessageInfo }, +thickRawThreadInfos: ThickRawThreadInfos, +auxUserInfos: AuxUserInfos, +messageDatasWithMessageInfos: ?$ReadOnlyArray<{ +messageData: MessageData, +rawMessageInfo: RawMessageInfo, }>, +userInfos: UserInfos, +getENSNames: ?GetENSNames, +getFCNames: ?GetFCNames, }; async function preparePushNotifs( inputData: PreparePushNotifsInputData, ): Promise { const { encryptedNotifUtilsAPI, senderDeviceDescriptor, olmSessionCreator, messageDatasWithMessageInfos, messageInfos, auxUserInfos, thickRawThreadInfos, userInfos, getENSNames, getFCNames, } = inputData; const { pushInfos } = await getPushUserInfo( messageInfos, thickRawThreadInfos, auxUserInfos, messageDatasWithMessageInfos, ); if (!pushInfos) { return null; } const filteredPushInfos = filterDevicesSupportingDMNotifsForUsers(pushInfos); const userDevices: { [userID: string]: $ReadOnlyArray, } = {}; for (const userID in filteredPushInfos) { userDevices[userID] = filteredPushInfos[userID].devices.map( device => device.cryptoID, ); } await createOlmSessionWithDevices(userDevices, olmSessionCreator); return await buildNotifsFromPushInfo({ encryptedNotifUtilsAPI, senderDeviceDescriptor, pushInfo: filteredPushInfos, thickRawThreadInfos, userInfos, getENSNames, getFCNames, }); } type PrepareOwnDevicesPushNotifsInputData = { +encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, +senderInfo: SenderInfo, +olmSessionCreator: ( userID: string, devices: $ReadOnlyArray, ) => Promise, +auxUserInfos: AuxUserInfos, +rescindData?: { threadID: string }, +badgeUpdateData?: { threadID: string }, }; async function prepareOwnDevicesPushNotifs( inputData: PrepareOwnDevicesPushNotifsInputData, ): Promise> { const { encryptedNotifUtilsAPI, senderInfo, olmSessionCreator, auxUserInfos, rescindData, badgeUpdateData, } = inputData; const ownDevicesPushInfo = getOwnDevicesPushInfo(senderInfo, auxUserInfos); if (!ownDevicesPushInfo) { return null; } const filteredownDevicesPushInfos = filterDevicesSupportingDMNotifs(ownDevicesPushInfo); const { senderUserID, senderDeviceDescriptor } = senderInfo; const userDevices: { +[userID: string]: $ReadOnlyArray, } = { [senderUserID]: filteredownDevicesPushInfos.devices.map( device => device.cryptoID, ), }; await createOlmSessionWithDevices(userDevices, olmSessionCreator); const devicesByPlatform = getDevicesByPlatform( filteredownDevicesPushInfos.devices, ); if (rescindData) { return await buildRescindsForOwnDevices( encryptedNotifUtilsAPI, senderDeviceDescriptor, devicesByPlatform, rescindData, ); } else if (badgeUpdateData) { return await buildBadgeUpdatesForOwnDevices( encryptedNotifUtilsAPI, senderDeviceDescriptor, devicesByPlatform, badgeUpdateData, ); } else { return null; } } export { preparePushNotifs, prepareOwnDevicesPushNotifs, generateNotifUserInfoPromise, pushInfoToCollapsableNotifInfo, mergeUserToCollapsableInfo, }; diff --git a/lib/push/web-notif-creators.js b/lib/push/web-notif-creators.js index 093e2a39a..ae93638be 100644 --- a/lib/push/web-notif-creators.js +++ b/lib/push/web-notif-creators.js @@ -1,70 +1,77 @@ // @flow import t, { type TInterface } from 'tcomb'; import { prepareEncryptedWebNotifications } from './crypto.js'; import { hasMinCodeVersion } from '../shared/version-utils.js'; import type { PlatformDetails } from '../types/device-types.js'; import { type NotificationTargetDevice, type TargetedWebNotification, type ResolvedNotifTexts, resolvedNotifTextsValidator, type SenderDeviceDescriptor, senderDeviceDescriptorValidator, type EncryptedNotifUtilsAPI, } from '../types/notif-types.js'; import { tID, tPlatformDetails, tShape } from '../utils/validation-utils.js'; export type WebNotifInputData = { +id: string, +notifTexts: ResolvedNotifTexts, +threadID: string, +senderDeviceDescriptor: SenderDeviceDescriptor, +unreadCount?: number, +platformDetails: PlatformDetails, }; export const webNotifInputDataValidator: TInterface = tShape({ id: t.String, notifTexts: resolvedNotifTextsValidator, threadID: tID, senderDeviceDescriptor: senderDeviceDescriptorValidator, unreadCount: t.maybe(t.Number), platformDetails: tPlatformDetails, }); async function createWebNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: WebNotifInputData, devices: $ReadOnlyArray, -): Promise<$ReadOnlyArray> { +): Promise<{ + +targetedNotifications: $ReadOnlyArray, +}> { const { id, notifTexts, threadID, unreadCount, senderDeviceDescriptor } = inputData; const { merged, ...rest } = notifTexts; const notification = { ...rest, unreadCount, id, threadID, }; const shouldBeEncrypted = hasMinCodeVersion(inputData.platformDetails, { web: 43, }); if (!shouldBeEncrypted) { - return devices.map(({ deliveryID }) => ({ deliveryID, notification })); + return { + targetedNotifications: devices.map(({ deliveryID }) => ({ + deliveryID, + notification, + })), + }; } - - return prepareEncryptedWebNotifications( + const targetedNotifications = await prepareEncryptedWebNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, ); + return { targetedNotifications }; } export { createWebNotification }; diff --git a/lib/push/wns-notif-creators.js b/lib/push/wns-notif-creators.js index d46e26be5..092572774 100644 --- a/lib/push/wns-notif-creators.js +++ b/lib/push/wns-notif-creators.js @@ -1,77 +1,82 @@ // @flow import t, { type TInterface } from 'tcomb'; import { prepareEncryptedWNSNotifications } from './crypto.js'; import { hasMinCodeVersion } from '../shared/version-utils.js'; import type { PlatformDetails } from '../types/device-types.js'; import { type NotificationTargetDevice, type TargetedWNSNotification, type ResolvedNotifTexts, resolvedNotifTextsValidator, type SenderDeviceDescriptor, senderDeviceDescriptorValidator, type EncryptedNotifUtilsAPI, } from '../types/notif-types.js'; import { tID, tPlatformDetails, tShape } from '../utils/validation-utils.js'; export const wnsMaxNotificationPayloadByteSize = 5000; export type WNSNotifInputData = { +notifTexts: ResolvedNotifTexts, +threadID: string, +senderDeviceDescriptor: SenderDeviceDescriptor, +unreadCount?: number, +platformDetails: PlatformDetails, }; export const wnsNotifInputDataValidator: TInterface = tShape({ notifTexts: resolvedNotifTextsValidator, threadID: tID, senderDeviceDescriptor: senderDeviceDescriptorValidator, unreadCount: t.maybe(t.Number), platformDetails: tPlatformDetails, }); async function createWNSNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: WNSNotifInputData, devices: $ReadOnlyArray, -): Promise<$ReadOnlyArray> { +): Promise<{ + +targetedNotifications: $ReadOnlyArray, +}> { const { notifTexts, threadID, unreadCount, senderDeviceDescriptor } = inputData; const { merged, ...rest } = notifTexts; const notification = { ...rest, unreadCount, threadID, }; if ( encryptedNotifUtilsAPI.getNotifByteSize(JSON.stringify(notification)) > wnsMaxNotificationPayloadByteSize ) { console.warn('WNS notification exceeds size limit'); } const shouldBeEncrypted = hasMinCodeVersion(inputData.platformDetails, { majorDesktop: 10, }); if (!shouldBeEncrypted) { - return devices.map(({ deliveryID }) => ({ - deliveryID, - notification, - })); + return { + targetedNotifications: devices.map(({ deliveryID }) => ({ + deliveryID, + notification, + })), + }; } - return await prepareEncryptedWNSNotifications( + const targetedNotifications = await prepareEncryptedWNSNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, ); + return { targetedNotifications }; } export { createWNSNotification };