diff --git a/keyserver/src/push/encrypted-notif-utils-api.js b/keyserver/src/push/encrypted-notif-utils-api.js index efe57e1a2..39ad0b0ba 100644 --- a/keyserver/src/push/encrypted-notif-utils-api.js +++ b/keyserver/src/push/encrypted-notif-utils-api.js @@ -1,56 +1,59 @@ // @flow import type { EncryptResult } from '@commapp/olm'; import type { EncryptedNotifUtilsAPI } from 'lib/types/notif-types.js'; import { blobServiceUpload } from './utils.js'; import { encryptAndUpdateOlmSession } from '../updaters/olm-session-updater.js'; +import { getOlmUtility } from '../utils/olm-utils.js'; const encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI = { encryptSerializedNotifPayload: async ( cryptoID: string, unencryptedPayload: string, encryptedPayloadSizeValidator?: ( encryptedPayload: string, type: '1' | '0', ) => boolean, ) => { let dbPersistCondition; if (encryptedPayloadSizeValidator) { dbPersistCondition = ({ serializedPayload, }: { +[string]: EncryptResult, }) => encryptedPayloadSizeValidator( serializedPayload.body, serializedPayload.type ? '1' : '0', ); } const { encryptedMessages: { serializedPayload }, dbPersistConditionViolated, encryptionOrder, } = await encryptAndUpdateOlmSession( cryptoID, 'notifications', { serializedPayload: unencryptedPayload, }, dbPersistCondition, ); return { encryptedData: serializedPayload, sizeLimitViolated: dbPersistConditionViolated, encryptionOrder, }; }, uploadLargeNotifPayload: blobServiceUpload, getNotifByteSize: (serializedPayload: string) => Buffer.byteLength(serializedPayload), + getEncryptedNotifHash: (serializedNotification: string) => + getOlmUtility().sha256(serializedNotification), }; export default encryptedNotifUtilsAPI; diff --git a/keyserver/src/push/providers.js b/keyserver/src/push/providers.js index 278823611..0de2e83c0 100644 --- a/keyserver/src/push/providers.js +++ b/keyserver/src/push/providers.js @@ -1,215 +1,205 @@ // @flow import apn from '@parse/node-apn'; import type { Provider as APNProvider } from '@parse/node-apn'; import fcmAdmin from 'firebase-admin'; import type { FirebaseApp } from 'firebase-admin'; import invariant from 'invariant'; import webpush from 'web-push'; import type { PlatformDetails } from 'lib/types/device-types'; import { getCommConfig } from 'lib/utils/comm-config.js'; type APNPushProfile = 'apn_config' | 'comm_apn_config'; function getAPNPushProfileForCodeVersion( platformDetails: PlatformDetails, ): APNPushProfile { if (platformDetails.platform === 'macos') { return 'comm_apn_config'; } return platformDetails.codeVersion && platformDetails.codeVersion >= 87 ? 'comm_apn_config' : 'apn_config'; } type FCMPushProfile = 'fcm_config' | 'comm_fcm_config'; function getFCMPushProfileForCodeVersion(codeVersion: ?number): FCMPushProfile { return codeVersion && codeVersion >= 87 ? 'comm_fcm_config' : 'fcm_config'; } type APNConfig = { +token: { +key: string, +keyId: string, +teamId: string, }, +production: boolean, }; const cachedAPNProviders = new Map(); async function getAPNProvider(profile: APNPushProfile): Promise { const provider = cachedAPNProviders.get(profile); if (provider !== undefined) { return provider; } try { const apnConfig = await getCommConfig({ folder: 'secrets', name: profile, }); invariant(apnConfig, `APN config missing for ${profile}`); if (!cachedAPNProviders.has(profile)) { cachedAPNProviders.set(profile, new apn.Provider(apnConfig)); } } catch { if (!cachedAPNProviders.has(profile)) { cachedAPNProviders.set(profile, null); } } return cachedAPNProviders.get(profile); } type FCMConfig = { +type: string, +project_id: string, +private_key_id: string, +private_key: string, +client_email: string, +client_id: string, +auth_uri: string, +token_uri: string, +auth_provider_x509_cert_url: string, +client_x509_cert_url: string, }; const cachedFCMProviders = new Map(); async function getFCMProvider(profile: FCMPushProfile): Promise { const provider = cachedFCMProviders.get(profile); if (provider !== undefined) { return provider; } try { const fcmConfig = await getCommConfig({ folder: 'secrets', name: profile, }); invariant(fcmConfig, `FCM config missed for ${profile}`); if (!cachedFCMProviders.has(profile)) { cachedFCMProviders.set( profile, fcmAdmin.initializeApp( { credential: fcmAdmin.credential.cert(fcmConfig), }, profile, ), ); } } catch { if (!cachedFCMProviders.has(profile)) { cachedFCMProviders.set(profile, null); } } return cachedFCMProviders.get(profile); } function endFirebase() { fcmAdmin.apps?.forEach(app => app?.delete()); } function endAPNs() { for (const provider of cachedAPNProviders.values()) { provider?.shutdown(); } } -function getAPNsNotificationTopic(platformDetails: PlatformDetails): string { - if (platformDetails.platform === 'macos') { - return 'app.comm.macos'; - } - return platformDetails.codeVersion && platformDetails.codeVersion >= 87 - ? 'app.comm' - : 'org.squadcal.app'; -} - type WebPushConfig = { +publicKey: string, +privateKey: string }; let cachedWebPushConfig: ?WebPushConfig = null; async function getWebPushConfig(): Promise { if (cachedWebPushConfig) { return cachedWebPushConfig; } cachedWebPushConfig = await getCommConfig({ folder: 'secrets', name: 'web_push_config', }); if (cachedWebPushConfig) { webpush.setVapidDetails( 'mailto:support@comm.app', cachedWebPushConfig.publicKey, cachedWebPushConfig.privateKey, ); } return cachedWebPushConfig; } async function ensureWebPushInitialized() { if (cachedWebPushConfig) { return; } await getWebPushConfig(); } type WNSConfig = { +tenantID: string, +appID: string, +secret: string }; type WNSAccessToken = { +token: string, +expires: number }; let wnsAccessToken: ?WNSAccessToken = null; async function getWNSToken(): Promise { const expiryWindowInMs = 10_000; const localWNSAccessToken = wnsAccessToken; if ( localWNSAccessToken && localWNSAccessToken.expires >= Date.now() - expiryWindowInMs ) { return localWNSAccessToken.token; } const config = await getCommConfig({ folder: 'secrets', name: 'wns_config', }); if (!config) { return null; } const params = new URLSearchParams(); params.append('grant_type', 'client_credentials'); params.append('client_id', config.appID); params.append('client_secret', config.secret); params.append('scope', 'https://wns.windows.com/.default'); try { const response = await fetch( `https://login.microsoftonline.com/${config.tenantID}/oauth2/v2.0/token`, { method: 'POST', body: params }, ); if (!response.ok) { console.error('Failure when getting the WNS token: ', response); return null; } const responseJson = await response.json(); wnsAccessToken = { token: responseJson['access_token'], expires: Date.now() + responseJson['expires_in'] * 1000, }; } catch (err) { console.error('Failure when getting the WNS token: ', err); return null; } return wnsAccessToken?.token; } export { getAPNPushProfileForCodeVersion, getFCMPushProfileForCodeVersion, getAPNProvider, getFCMProvider, endFirebase, endAPNs, - getAPNsNotificationTopic, getWebPushConfig, ensureWebPushInitialized, getWNSToken, }; diff --git a/keyserver/src/push/rescind.js b/keyserver/src/push/rescind.js index 99d0ac5ee..10afe9358 100644 --- a/keyserver/src/push/rescind.js +++ b/keyserver/src/push/rescind.js @@ -1,384 +1,384 @@ // @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 { getAPNsNotificationTopic } from './providers.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( encryptedNotifUtilsAPI, { senderDeviceDescriptor: { keyserverID }, badge: unreadCount.toString(), platformDetails, rescindID: notifID, threadID, }, devices, ); } export { rescindPushNotifs }; diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js index c7372cef5..6a39b0c8a 100644 --- a/keyserver/src/push/send.js +++ b/keyserver/src/push/send.js @@ -1,1702 +1,1704 @@ // @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 WebNotifInputData, webNotifInputDataValidator, createWebNotification, } from 'lib/push/web-notif-creators.js'; import { type WNSNotifInputData, wnsNotifInputDataValidator, createWNSNotification, } from 'lib/push/wns-notif-creators.js'; import { oldValidUsernameRegex } from 'lib/shared/account-utils.js'; import { isUserMentioned } from 'lib/shared/mention-utils.js'; import { createMessageInfo, shimUnsupportedRawMessageInfos, sortMessageInfoList, } from 'lib/shared/message-utils.js'; import { messageSpecs } from 'lib/shared/messages/message-specs.js'; -import { notifTextsForMessageInfo } from 'lib/shared/notif-utils.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 MessageData, 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 { getAPNsNotificationTopic } from './providers.js'; import { rescindPushNotifs } from './rescind.js'; import type { TargetedAPNsNotification } from './types.js'; import { - apnMaxNotificationPayloadByteSize, 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 type { CollapsableNotifInfo } from '../fetchers/message-fetchers.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'; export type Device = { +platform: Platform, +deviceToken: string, +cookieID: string, +codeVersion: ?number, +stateVersion: ?number, +majorDesktopVersion: ?number, }; export type PushUserInfo = { +devices: Device[], // messageInfos and messageDatas have the same key +messageInfos: RawMessageInfo[], +messageDatas: MessageData[], }; type Delivery = PushDelivery | { collapsedInto: string }; type NotificationRow = { +dbID: string, +userID: string, +threadID?: ?string, +messageID?: ?string, +collapseKey?: ?string, +deliveries: Delivery[], }; export type PushInfo = { [userID: string]: PushUserInfo }; 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 updateBadge = threadInfo.currentUser.subscription.home; const displayBanner = threadInfo.currentUser.subscription.pushNotifs; const username = userInfos[userID] && userInfos[userID].username; let resolvedUsername; if (getENSNames) { const userInfosWithENSNames = await getENSNames([userInfos[userID]]); resolvedUsername = userInfosWithENSNames[0].username; } const userWasMentioned = username && threadInfo.currentUser.role && oldValidUsernameRegex.test(username) && newMessageInfos.some(newMessageInfo => { const unwrappedMessageInfo = newMessageInfo.type === messageTypes.SIDEBAR_SOURCE ? newMessageInfo.sourceMessage : newMessageInfo; return ( unwrappedMessageInfo.type === messageTypes.TEXT && (isUserMentioned(username, unwrappedMessageInfo.text) || (resolvedUsername && isUserMentioned(resolvedUsername, unwrappedMessageInfo.text))) ); }); if (!updateBadge && !displayBanner && !userWasMentioned) { return null; } const badgeOnly = !displayBanner && !userWasMentioned; 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]: CollapsableNotifInfo[] }, ) { 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 VersionKey = { +codeVersion: number, +stateVersion: number, +majorDesktopVersion?: number, }; const versionKeyRegex: RegExp = new RegExp(/^-?\d+\|-?\d+(\|-?\d+)?$/); function versionKeyToString(versionKey: VersionKey): string { const baseStringVersionKey = `${versionKey.codeVersion}|${versionKey.stateVersion}`; if (!versionKey.majorDesktopVersion) { return baseStringVersionKey; } return `${baseStringVersionKey}|${versionKey.majorDesktopVersion}`; } function stringToVersionKey(versionKeyString: string): VersionKey { invariant( versionKeyRegex.test(versionKeyString), 'should pass correct version key string', ); const [codeVersion, stateVersion, majorDesktopVersion] = versionKeyString .split('|') .map(Number); return { codeVersion, stateVersion, majorDesktopVersion }; } function getDevicesByPlatform( devices: $ReadOnlyArray, ): Map>> { const byPlatform = new Map< Platform, Map>, >(); for (const device of devices) { let innerMap = byPlatform.get(device.platform); if (!innerMap) { innerMap = new Map>(); byPlatform.set(device.platform, innerMap); } const codeVersion: number = device.codeVersion !== null && device.codeVersion !== undefined ? device.codeVersion : -1; const stateVersion: number = device.stateVersion ?? -1; let versionsObject = { codeVersion, stateVersion }; if (device.majorDesktopVersion) { versionsObject = { ...versionsObject, majorDesktopVersion: device.majorDesktopVersion, }; } const versionKey = versionKeyToString(versionsObject); let innerMostArrayTmp: ?Array = innerMap.get(versionKey); if (!innerMostArrayTmp) { innerMostArrayTmp = []; innerMap.set(versionKey, innerMostArrayTmp); } const innerMostArray = innerMostArrayTmp; innerMostArray.push({ cryptoID: device.cookieID, deliveryID: device.deviceToken, }); } return byPlatform; } 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( encryptedNotifUtilsAPI, convertedData, devices, ); } async function prepareWebNotification( inputData: WebNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = await validateOutput( inputData.platformDetails, webNotifInputDataValidator, inputData, ); return createWebNotification(encryptedNotifUtilsAPI, convertedData, devices); } async function prepareWNSNotification( devices: $ReadOnlyArray, inputData: WNSNotifInputData, ): Promise<$ReadOnlyArray> { const convertedData = await validateOutput( inputData.platformDetails, wnsNotifInputDataValidator, inputData, ); return createWNSNotification(encryptedNotifUtilsAPI, convertedData, devices); } 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 = deviceTokenResult.map(row => { const versions = JSON.parse(row.versions); return { platform: row.platform, cookieID: row.id, deviceToken: row.device_token, 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 = 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/keyserver/src/push/utils.js b/keyserver/src/push/utils.js index 53983c8fd..41db3657f 100644 --- a/keyserver/src/push/utils.js +++ b/keyserver/src/push/utils.js @@ -1,452 +1,446 @@ // @flow import type { ResponseFailure } from '@parse/node-apn'; import type { FirebaseApp, FirebaseError } from 'firebase-admin'; import invariant from 'invariant'; import nodeFetch from 'node-fetch'; import type { Response } from 'node-fetch'; import uuid from 'uuid'; import webpush from 'web-push'; -import { fcmMaxNotificationPayloadByteSize } from 'lib/push/android-notif-creators.js'; -import { wnsMaxNotificationPayloadByteSize } from 'lib/push/wns-notif-creators.js'; import type { PlatformDetails } from 'lib/types/device-types.js'; import type { TargetedAndroidNotification, TargetedWebNotification, TargetedWNSNotification, } from 'lib/types/notif-types.js'; import { threadSubscriptions } from 'lib/types/subscription-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { encryptBlobPayload } from './crypto.js'; import { getAPNPushProfileForCodeVersion, getFCMPushProfileForCodeVersion, getAPNProvider, getFCMProvider, ensureWebPushInitialized, getWNSToken, } from './providers.js'; import type { TargetedAPNsNotification } from './types.js'; import { dbQuery, SQL } from '../database/database.js'; import { upload, assignHolder } from '../services/blob.js'; const fcmTokenInvalidationErrors = new Set([ 'messaging/registration-token-not-registered', 'messaging/invalid-registration-token', ]); const apnTokenInvalidationErrorCode = 410; const apnBadRequestErrorCode = 400; const apnBadTokenErrorString = 'BadDeviceToken'; -const apnMaxNotificationPayloadByteSize = 4096; const webInvalidTokenErrorCodes = [404, 410]; const wnsInvalidTokenErrorCodes = [404, 410]; export type APNPushResult = | { +success: true } | { +errors: $ReadOnlyArray, +invalidTokens?: $ReadOnlyArray, }; async function apnPush({ targetedNotifications, platformDetails, }: { +targetedNotifications: $ReadOnlyArray, +platformDetails: PlatformDetails, }): Promise { const pushProfile = getAPNPushProfileForCodeVersion(platformDetails); const apnProvider = await getAPNProvider(pushProfile); if (!apnProvider && process.env.NODE_ENV === 'development') { console.log(`no keyserver/secrets/${pushProfile}.json so ignoring notifs`); return { success: true }; } invariant(apnProvider, `keyserver/secrets/${pushProfile}.json should exist`); const results = await Promise.all( targetedNotifications.map(({ notification, deliveryID }) => { return apnProvider.send(notification, deliveryID); }), ); const errors: Array = []; for (const result of results) { errors.push(...result.failed); } const invalidTokens: Array = []; for (const error of errors) { /* eslint-disable eqeqeq */ if ( error.status == apnTokenInvalidationErrorCode || (error.status == apnBadRequestErrorCode && error.response.reason === apnBadTokenErrorString) ) { invalidTokens.push(error.device); } /* eslint-enable eqeqeq */ } if (invalidTokens.length > 0) { return { errors, invalidTokens }; } else if (errors.length > 0) { return { errors }; } else { return { success: true }; } } type WritableFCMPushResult = { success?: true, fcmIDs?: $ReadOnlyArray, errors?: $ReadOnlyArray, invalidTokens?: $ReadOnlyArray, }; export type FCMPushResult = $ReadOnly; async function fcmPush({ targetedNotifications, collapseKey, codeVersion, }: { +targetedNotifications: $ReadOnlyArray, +codeVersion: ?number, +collapseKey?: ?string, }): Promise { const pushProfile = getFCMPushProfileForCodeVersion(codeVersion); const fcmProvider = await getFCMProvider(pushProfile); if (!fcmProvider && process.env.NODE_ENV === 'development') { console.log(`no keyserver/secrets/${pushProfile}.json so ignoring notifs`); return { success: true }; } invariant(fcmProvider, `keyserver/secrets/${pushProfile}.json should exist`); const options: Object = {}; if (collapseKey) { options.collapseKey = collapseKey; } // firebase-admin is extremely barebones and has a lot of missing or poorly // thought-out functionality. One of the issues is that if you send a // multicast messages and one of the device tokens is invalid, the resultant // won't explain which of the device tokens is invalid. So we're forced to // avoid the multicast functionality and call it once per deviceToken. const results = await Promise.all( targetedNotifications.map(({ notification, deliveryID, priority }) => { return fcmSinglePush(fcmProvider, notification, deliveryID, { ...options, priority, }); }), ); const errors = []; const ids = []; const invalidTokens = []; for (let i = 0; i < results.length; i++) { const pushResult = results[i]; for (const error of pushResult.errors) { errors.push(error.error); const errorCode = error.type === 'firebase_error' ? error.error.errorInfo.code : undefined; if (errorCode && fcmTokenInvalidationErrors.has(errorCode)) { invalidTokens.push(targetedNotifications[i].deliveryID); } } for (const id of pushResult.fcmIDs) { ids.push(id); } } const result: WritableFCMPushResult = {}; if (ids.length > 0) { result.fcmIDs = ids; } if (errors.length > 0) { result.errors = errors; } else { result.success = true; } if (invalidTokens.length > 0) { result.invalidTokens = invalidTokens; } return result; } type FCMSinglePushError = | { +type: 'firebase_error', +error: FirebaseError } | { +type: 'exception', +error: mixed }; type FCMSinglePushResult = { +fcmIDs: $ReadOnlyArray, +errors: $ReadOnlyArray, }; async function fcmSinglePush( provider: FirebaseApp, notification: Object, deviceToken: string, options: Object, ): Promise { try { const deliveryResult = await provider .messaging() .sendToDevice(deviceToken, notification, options); const errors = []; const ids = []; for (const fcmResult of deliveryResult.results) { if (fcmResult.error) { errors.push({ type: 'firebase_error', error: fcmResult.error }); } else if (fcmResult.messageId) { ids.push(fcmResult.messageId); } } return { fcmIDs: ids, errors }; } catch (e) { return { fcmIDs: [], errors: [{ type: 'exception', error: e }] }; } } async function getUnreadCounts( userIDs: string[], ): Promise<{ [userID: string]: number }> { const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; const notificationExtractString = `$.${threadSubscriptions.home}`; const query = SQL` SELECT user, COUNT(thread) AS unread_count FROM memberships WHERE user IN (${userIDs}) AND last_message > last_read_message AND role > 0 AND JSON_EXTRACT(permissions, ${visPermissionExtractString}) AND JSON_EXTRACT(subscription, ${notificationExtractString}) GROUP BY user `; const [result] = await dbQuery(query); const usersToUnreadCounts: { [string]: number } = {}; for (const row of result) { usersToUnreadCounts[row.user.toString()] = row.unread_count; } for (const userID of userIDs) { if (usersToUnreadCounts[userID] === undefined) { usersToUnreadCounts[userID] = 0; } } return usersToUnreadCounts; } export type WebPushError = { +statusCode: number, +headers: { +[string]: string }, +body: string, }; type WritableWebPushResult = { success?: true, errors?: $ReadOnlyArray, invalidTokens?: $ReadOnlyArray, }; type WebPushResult = $ReadOnly; type WebPushAttempt = { +error?: WebPushError, }; async function webPush( targetedNotifications: $ReadOnlyArray, ): Promise { await ensureWebPushInitialized(); const pushResults: $ReadOnlyArray = await Promise.all( targetedNotifications.map( async ({ notification, deliveryID: deviceTokenString }) => { const deviceToken: PushSubscriptionJSON = JSON.parse(deviceTokenString); const notificationString = JSON.stringify(notification); try { await webpush.sendNotification(deviceToken, notificationString); } catch (error) { return ({ error }: WebPushAttempt); } return {}; }, ), ); const errors = []; const invalidTokens = []; const deviceTokens = targetedNotifications.map( ({ deliveryID }) => deliveryID, ); for (let i = 0; i < pushResults.length; i++) { const pushResult = pushResults[i]; const { error } = pushResult; if (error) { errors.push(error); if (webInvalidTokenErrorCodes.includes(error.statusCode)) { invalidTokens.push(deviceTokens[i]); } } } const result: WritableWebPushResult = {}; if (errors.length > 0) { result.errors = errors; } else { result.success = true; } if (invalidTokens.length > 0) { result.invalidTokens = invalidTokens; } return result; } export type WNSPushError = any | string | Response; type WritableWNSPushResult = { success?: true, wnsIDs?: $ReadOnlyArray, errors?: $ReadOnlyArray, invalidTokens?: $ReadOnlyArray, }; type WNSPushResult = $ReadOnly; async function wnsPush( targetedNotifications: $ReadOnlyArray, ): Promise { const token = await getWNSToken(); if (!token && process.env.NODE_ENV === 'development') { console.log(`no keyserver/secrets/wns_config.json so ignoring notifs`); return { success: true }; } invariant(token, `keyserver/secrets/wns_config.json should exist`); const pushResults = targetedNotifications.map(async targetedNotification => { const notificationString = JSON.stringify( targetedNotification.notification, ); try { return await wnsSinglePush( token, notificationString, targetedNotification.deliveryID, ); } catch (error) { return { error }; } }); const errors = []; const notifIDs = []; const invalidTokens = []; const deviceTokens = targetedNotifications.map( ({ deliveryID }) => deliveryID, ); for (let i = 0; i < pushResults.length; i++) { const pushResult = await pushResults[i]; if (pushResult.error) { errors.push(pushResult.error); if ( pushResult.error === 'invalidDomain' || wnsInvalidTokenErrorCodes.includes(pushResult.error?.status) ) { invalidTokens.push(deviceTokens[i]); } } else { notifIDs.push(pushResult.wnsID); } } const result: WritableWNSPushResult = {}; if (notifIDs.length > 0) { result.wnsIDs = notifIDs; } if (errors.length > 0) { result.errors = errors; } else { result.success = true; } if (invalidTokens.length > 0) { result.invalidTokens = invalidTokens; } return result; } async function wnsSinglePush(token: string, notification: string, url: string) { const parsedURL = new URL(url); const domain = parsedURL.hostname.split('.').slice(-3); if ( domain[0] !== 'notify' || domain[1] !== 'windows' || domain[2] !== 'com' ) { return { error: 'invalidDomain' }; } try { const result = await nodeFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'X-WNS-Type': 'wns/raw', 'Authorization': `Bearer ${token}`, }, body: notification, }); if (!result.ok) { return { error: result }; } const wnsID = result.headers.get('X-WNS-MSG-ID'); invariant(wnsID, 'Missing WNS ID'); return { wnsID }; } catch (err) { return { error: err }; } } async function blobServiceUpload( payload: string, numberOfHolders: number, ): Promise< | { +blobHolders: $ReadOnlyArray, +blobHash: string, +encryptionKey: string, } | { +blobUploadError: string }, > { const blobHolders = Array.from({ length: numberOfHolders }, () => uuid.v4()); try { const { encryptionKey, encryptedPayload, encryptedPayloadHash } = await encryptBlobPayload(payload); const [blobHolder, ...additionalHolders] = blobHolders; const uploadPromise = upload(encryptedPayload, { hash: encryptedPayloadHash, holder: blobHolder, }); const additionalHoldersPromises = additionalHolders.map(holder => assignHolder({ hash: encryptedPayloadHash, holder }), ); await Promise.all([uploadPromise, Promise.all(additionalHoldersPromises)]); return { blobHash: encryptedPayloadHash, blobHolders, encryptionKey, }; } catch (e) { return { blobUploadError: e.message, }; } } export { apnPush, blobServiceUpload, fcmPush, webPush, wnsPush, getUnreadCounts, - apnMaxNotificationPayloadByteSize, - fcmMaxNotificationPayloadByteSize, - wnsMaxNotificationPayloadByteSize, }; diff --git a/lib/push/android-notif-creators.js b/lib/push/android-notif-creators.js index 59106ce34..08faf3cea 100644 --- a/lib/push/android-notif-creators.js +++ b/lib/push/android-notif-creators.js @@ -1,406 +1,407 @@ // @flow import invariant from 'invariant'; import t, { type TInterface } from 'tcomb'; import { prepareEncryptedAndroidVisualNotifications, prepareEncryptedAndroidSilentNotifications, } from './crypto.js'; import { hasMinCodeVersion, FUTURE_CODE_VERSION, } 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; -type CommonNativeNotifInputData = $ReadOnly<{ +export type CommonNativeNotifInputData = $ReadOnly<{ +senderDeviceDescriptor: SenderDeviceDescriptor, +notifTexts: ResolvedNotifTexts, +newRawMessageInfos: RawMessageInfo[], +threadID: string, +collapseKey: ?string, +badgeOnly: boolean, +unreadCount?: number, +platformDetails: PlatformDetails, }>; -const commonNativeNotifInputDataValidator = 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 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 = { ...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> { 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) { + 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, })); } 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, }), ); } const canQueryBlobService = hasMinCodeVersion(platformDetails, { native: 331, }); let blobHash, blobHolders, encryptionKey, blobUploadError; if (canQueryBlobService) { ({ blobHash, blobHolders, encryptionKey, blobUploadError } = await encryptedNotifUtilsAPI.uploadLargeNotifPayload( JSON.stringify(copyWithMessageInfos.data), 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 [ ...targetedNotifsWithMessageInfos, ...targetedNotifsWithoutMessageInfos, ]; } type AndroidNotificationRescindInputData = { +senderDeviceDescriptor: SenderDeviceDescriptor, +threadID: string, +rescindID?: string, +badge?: string, +platformDetails: PlatformDetails, }; async function createAndroidNotificationRescind( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: AndroidNotificationRescindInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const { senderDeviceDescriptor, platformDetails, threadID, rescindID, badge, } = inputData; let notification = { data: { rescind: 'true', setUnreadStatus: 'true', threadID, }, }; invariant( (rescindID && badge) || hasMinCodeVersion(platformDetails, { native: FUTURE_CODE_VERSION }), 'thick thread rescind not support for this client version', ); 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', })); } const notifications = await prepareEncryptedAndroidSilentNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, ); return 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> { const { senderDeviceDescriptor, platformDetails, badge, threadID } = inputData; invariant( (!threadID && badge) || hasMinCodeVersion(platformDetails, { native: FUTURE_CODE_VERSION }), 'thick thread badge updates not support for this client version', ); 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', })); } const notifications = await prepareEncryptedAndroidSilentNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, ); return 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 new file mode 100644 index 000000000..30b0ca971 --- /dev/null +++ b/lib/push/apns-notif-creators.js @@ -0,0 +1,504 @@ +// @flow + +import invariant from 'invariant'; +import t, { type TInterface } from 'tcomb'; + +import { + type CommonNativeNotifInputData, + commonNativeNotifInputDataValidator, +} from './android-notif-creators.js'; +import { + prepareEncryptedAPNsVisualNotifications, + prepareEncryptedAPNsSilentNotifications, +} from './crypto.js'; +import { getAPNsNotificationTopic } from '../shared/notif-utils.js'; +import { + hasMinCodeVersion, + FUTURE_CODE_VERSION, +} 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 = { + ...CommonNativeNotifInputData, + +badgeOnly: boolean, + +uniqueID: string, +}; + +export const apnsNotifInputDataValidator: TInterface = + tShape({ + ...commonNativeNotifInputDataValidator.meta.props, + badgeOnly: t.Boolean, + uniqueID: t.String, + }); + +async function createAPNsVisualNotification( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + inputData: APNsNotifInputData, + devices: $ReadOnlyArray, +): Promise<$ReadOnlyArray> { + 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 }) => ({ + 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 prepareEncryptedAPNsVisualNotifications( + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + devices, + notification, + platformDetails.codeVersion, + ); + return macOSNotifsWithoutMessageInfos.map( + ({ notification: notif, deliveryID }) => ({ + notification: notif, + deliveryID, + }), + ); + } + + 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( + ({ + 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( + serializeAPNsNotif(copyWithMessageInfos), + 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 [ + ...targetedNotifsWithMessageInfos, + ...targetedNotifsWithoutMessageInfos, + ]; +} + +type APNsNotificationRescindInputData = { + +senderDeviceDescriptor: SenderDeviceDescriptor, + +rescindID?: string, + +badge?: number, + +threadID: string, + +platformDetails: PlatformDetails, +}; + +async function createAPNsNotificationRescind( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + inputData: APNsNotificationRescindInputData, + devices: $ReadOnlyArray, +): Promise<$ReadOnlyArray> { + const { + badge, + rescindID, + threadID, + platformDetails, + senderDeviceDescriptor, + } = inputData; + + invariant( + (rescindID && badge !== null && badge !== undefined) || + hasMinCodeVersion(platformDetails, { native: FUTURE_CODE_VERSION }), + 'thick thread rescind not support for this client version', + ); + + 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, + })); + } + + const notifications = await prepareEncryptedAPNsSilentNotifications( + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + devices, + notification, + platformDetails.codeVersion, + ); + + return notifications.map(({ deliveryID, notification: notif }) => ({ + deliveryID, + notification: notif, + })); +} + +type APNsBadgeOnlyNotificationInputData = { + +senderDeviceDescriptor: SenderDeviceDescriptor, + +badge?: number, + +threadID?: string, + +platformDetails: PlatformDetails, +}; + +async function createAPNsBadgeOnlyNotification( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + inputData: APNsBadgeOnlyNotificationInputData, + devices: $ReadOnlyArray, +): Promise<$ReadOnlyArray> { + const { senderDeviceDescriptor, platformDetails, threadID, badge } = + inputData; + invariant( + (!threadID && badge !== undefined && badge !== null) || + hasMinCodeVersion(platformDetails, { native: FUTURE_CODE_VERSION }), + 'thick thread badge updates not support for this client version', + ); + + 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, + })); + } + + const notifications = await prepareEncryptedAPNsSilentNotifications( + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + devices, + notification, + platformDetails.codeVersion, + ); + + return 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 79be6b78a..49a358e7a 100644 --- a/lib/push/crypto.js +++ b/lib/push/crypto.js @@ -1,385 +1,619 @@ // @flow +import invariant from 'invariant'; + 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, - cookieID: string, + 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( - cookieID, + 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, + aps: { sound }, + merged: alert, + badge, + }; + + if (blobHolder) { + unencryptedPayload = { ...unencryptedPayload, blobHolder }; + } + + try { + const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); + + let encryptedNotifAps = { 'mutable-content': 1 }; + 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 = 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 = 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, - cookieID: string, + 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, - cookieID, + cryptoID, senderDeviceDescriptor, unencryptedPayload, payloadSizeValidator, ); return { notification: { data: { ...unencryptedData, ...resultPayload, }, }, payloadSizeExceeded, encryptionOrder, }; } async function encryptAndroidSilentNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - cookieID: string, + 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, - cookieID, + cryptoID, senderDeviceDescriptor, unencryptedPayload, ); if (resultPayload.encryptedPayload) { return { data: { ...resultPayload }, }; } if (resultPayload.rescind) { return { data: { ...resultPayload }, }; } return { data: { ...resultPayload, }, }; } async function encryptBasicPayload( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - cookieID: string, + 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( - cookieID, + 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, - cookieID: string, + cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, notification: PlainTextWebNotification, ): Promise<{ +notification: WebNotification, +encryptionOrder?: number }> { const { id, ...payloadSansId } = notification; const { encryptionOrder, ...encryptionResult } = await encryptBasicPayload( encryptedNotifUtilsAPI, - cookieID, + cryptoID, senderDeviceDescriptor, payloadSansId, ); return { notification: { id, ...encryptionResult }, encryptionOrder, }; } async function encryptWNSNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, - cookieID: string, + cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, notification: PlainTextWNSNotification, ): Promise<{ +notification: WNSNotification, +encryptionOrder?: number }> { const { encryptionOrder, ...encryptionResult } = await encryptBasicPayload( encryptedNotifUtilsAPI, - cookieID, + 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 { + prepareEncryptedAPNsVisualNotifications, + prepareEncryptedAPNsSilentNotifications, prepareEncryptedAndroidVisualNotifications, prepareEncryptedAndroidSilentNotifications, prepareEncryptedWebNotifications, prepareEncryptedWNSNotifications, }; diff --git a/lib/shared/notif-utils.js b/lib/shared/notif-utils.js index 668d9f0e9..5869c93ae 100644 --- a/lib/shared/notif-utils.js +++ b/lib/shared/notif-utils.js @@ -1,332 +1,349 @@ // @flow import invariant from 'invariant'; import { isUserMentioned } from './mention-utils.js'; import { robotextForMessageInfo } from './message-utils.js'; import type { NotificationTextsParams } from './messages/message-spec.js'; import { messageSpecs } from './messages/message-specs.js'; import { threadNoun } from './thread-utils.js'; +import { type PlatformDetails } from '../types/device-types.js'; import { type MessageType, messageTypes } from '../types/message-types-enum.js'; import { type MessageData, type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type SidebarSourceMessageInfo, } from '../types/message-types.js'; import type { CreateSidebarMessageInfo } from '../types/messages/create-sidebar.js'; import type { TextMessageInfo } from '../types/messages/text.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { NotifTexts, ResolvedNotifTexts } from '../types/notif-types.js'; +import type { + NotifTexts, + ResolvedNotifTexts, + APNsNotificationTopic, +} from '../types/notif-types.js'; import { type ThreadType, threadTypes } from '../types/thread-types-enum.js'; import type { RelativeUserInfo, UserInfo } from '../types/user-types.js'; import { prettyDate } from '../utils/date-utils.js'; import type { GetENSNames } from '../utils/ens-helpers.js'; import { type EntityText, ET, getEntityTextAsString, type ThreadEntity, } from '../utils/entity-text.js'; import type { GetFCNames } from '../utils/farcaster-helpers.js'; import { promiseAll } from '../utils/promises.js'; import { trimText } from '../utils/text-utils.js'; async function notifTextsForMessageInfo( messageInfos: MessageInfo[], threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, notifTargetUserInfo: UserInfo, getENSNames: ?GetENSNames, getFCNames: ?GetFCNames, ): Promise { const fullNotifTexts = await fullNotifTextsForMessageInfo( messageInfos, threadInfo, parentThreadInfo, notifTargetUserInfo, getENSNames, getFCNames, ); if (!fullNotifTexts) { return fullNotifTexts; } const merged = trimText(fullNotifTexts.merged, 300); const body = trimText(fullNotifTexts.body, 300); const title = trimText(fullNotifTexts.title, 100); if (!fullNotifTexts.prefix) { return { merged, body, title }; } const prefix = trimText(fullNotifTexts.prefix, 50); return { merged, body, title, prefix }; } function notifTextsForEntryCreationOrEdit( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): NotifTexts { const hasCreateEntry = messageInfos.some( messageInfo => messageInfo.type === messageTypes.CREATE_ENTRY, ); const messageInfo = messageInfos[0]; const thread = ET.thread({ display: 'shortName', threadInfo }); const creator = ET.user({ userInfo: messageInfo.creator }); const prefix = ET`${creator}`; if (!hasCreateEntry) { invariant( messageInfo.type === messageTypes.EDIT_ENTRY, 'messageInfo should be messageTypes.EDIT_ENTRY!', ); const date = prettyDate(messageInfo.date); let body = ET`updated the text of an event in ${thread}`; body = ET`${body} scheduled for ${date}: "${messageInfo.text}"`; const merged = ET`${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } invariant( messageInfo.type === messageTypes.CREATE_ENTRY || messageInfo.type === messageTypes.EDIT_ENTRY, 'messageInfo should be messageTypes.CREATE_ENTRY/EDIT_ENTRY!', ); const date = prettyDate(messageInfo.date); let body = ET`created an event in ${thread}`; body = ET`${body} scheduled for ${date}: "${messageInfo.text}"`; const merged = ET`${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } type NotifTextsForSubthreadCreationInput = { +creator: RelativeUserInfo, +threadType: ThreadType, +parentThreadInfo: ThreadInfo, +childThreadName: ?string, +childThreadUIName: string | ThreadEntity, }; function notifTextsForSubthreadCreation( input: NotifTextsForSubthreadCreationInput, ): NotifTexts { const { creator, threadType, parentThreadInfo, childThreadName, childThreadUIName, } = input; const prefix = ET`${ET.user({ userInfo: creator })}`; let body: string | EntityText = `created a new ${threadNoun( threadType, parentThreadInfo.id, )}`; if (parentThreadInfo.name && parentThreadInfo.type !== threadTypes.GENESIS) { body = ET`${body} in ${parentThreadInfo.name}`; } let merged = ET`${prefix} ${body}`; if (childThreadName) { merged = ET`${merged} called "${childThreadName}"`; } return { merged, body, title: childThreadUIName, prefix, }; } type NotifTextsForSidebarCreationInput = { +createSidebarMessageInfo: CreateSidebarMessageInfo, +sidebarSourceMessageInfo?: ?SidebarSourceMessageInfo, +firstSidebarMessageInfo?: ?TextMessageInfo, +threadInfo: ThreadInfo, +params: NotificationTextsParams, }; function notifTextsForSidebarCreation( input: NotifTextsForSidebarCreationInput, ): NotifTexts { const { sidebarSourceMessageInfo, createSidebarMessageInfo, firstSidebarMessageInfo, threadInfo, params, } = input; const creator = ET.user({ userInfo: createSidebarMessageInfo.creator }); const prefix = ET`${creator}`; const initialName = createSidebarMessageInfo.initialThreadState.name; const sourceMessageAuthorPossessive = ET.user({ userInfo: createSidebarMessageInfo.sourceMessageAuthor, possessive: true, }); let body: string | EntityText = 'started a thread in response to'; body = ET`${body} ${sourceMessageAuthorPossessive} message`; const { username } = params.notifTargetUserInfo; if ( username && sidebarSourceMessageInfo && sidebarSourceMessageInfo.sourceMessage.type === messageTypes.TEXT && isUserMentioned(username, sidebarSourceMessageInfo.sourceMessage.text) ) { body = ET`${body} that tagged you`; } else if ( username && firstSidebarMessageInfo && isUserMentioned(username, firstSidebarMessageInfo.text) ) { body = ET`${body} and tagged you`; } else if (initialName) { body = ET`${body} "${initialName}"`; } return { merged: ET`${prefix} ${body}`, body, title: threadInfo.uiName, prefix, }; } function mostRecentMessageInfoType( messageInfos: $ReadOnlyArray, ): MessageType { if (messageInfos.length === 0) { throw new Error('expected MessageInfo, but none present!'); } return messageInfos[0].type; } async function fullNotifTextsForMessageInfo( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, notifTargetUserInfo: UserInfo, getENSNames: ?GetENSNames, getFCNames: ?GetFCNames, ): Promise { const mostRecentType = mostRecentMessageInfoType(messageInfos); const messageSpec = messageSpecs[mostRecentType]; invariant( messageSpec.notificationTexts, `we're not aware of messageType ${mostRecentType}`, ); const unresolvedNotifTexts = await messageSpec.notificationTexts( messageInfos, threadInfo, { notifTargetUserInfo, parentThreadInfo }, ); if (!unresolvedNotifTexts) { return unresolvedNotifTexts; } const resolveToString = async ( entityText: string | EntityText, ): Promise => { if (typeof entityText === 'string') { return entityText; } const notifString = await getEntityTextAsString( entityText, { getENSNames, getFCNames }, { prefixThisThreadNounWith: 'your' }, ); invariant( notifString !== null && notifString !== undefined, 'getEntityTextAsString only returns falsey when passed falsey', ); return notifString; }; let promises = { merged: resolveToString(unresolvedNotifTexts.merged), body: resolveToString(unresolvedNotifTexts.body), title: resolveToString(ET`${unresolvedNotifTexts.title}`), }; if (unresolvedNotifTexts.prefix) { promises = { ...promises, prefix: resolveToString(unresolvedNotifTexts.prefix), }; } return await promiseAll(promises); } function notifRobotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ): EntityText { const robotext = robotextForMessageInfo( messageInfo, threadInfo, parentThreadInfo, ); return robotext.map(entity => { if ( typeof entity !== 'string' && entity.type === 'thread' && entity.id === threadInfo.id ) { return ET.thread({ display: 'shortName', threadInfo, possessive: entity.possessive, }); } return entity; }); } function getNotifCollapseKey( rawMessageInfo: RawMessageInfo, messageData: MessageData, ): ?string { const messageSpec = messageSpecs[rawMessageInfo.type]; return ( messageSpec.notificationCollapseKey?.(rawMessageInfo, messageData) ?? null ); } type Unmerged = $ReadOnly<{ body: string, title: string, prefix?: string, ... }>; type Merged = { body: string, title: string, }; function mergePrefixIntoBody(unmerged: Unmerged): Merged { const { body, title, prefix } = unmerged; const merged = prefix ? `${prefix} ${body}` : body; return { body: merged, title }; } +function getAPNsNotificationTopic( + platformDetails: PlatformDetails, +): APNsNotificationTopic { + if (platformDetails.platform === 'macos') { + return 'app.comm.macos'; + } + return platformDetails.codeVersion && platformDetails.codeVersion >= 87 + ? 'app.comm' + : 'org.squadcal.app'; +} + export { notifRobotextForMessageInfo, notifTextsForMessageInfo, notifTextsForEntryCreationOrEdit, notifTextsForSubthreadCreation, notifTextsForSidebarCreation, getNotifCollapseKey, mergePrefixIntoBody, + getAPNsNotificationTopic, }; diff --git a/lib/types/notif-types.js b/lib/types/notif-types.js index 7ca08eb12..01966095a 100644 --- a/lib/types/notif-types.js +++ b/lib/types/notif-types.js @@ -1,236 +1,390 @@ // @flow import type { EncryptResult } from '@commapp/olm'; import t, { type TInterface, type TUnion } from 'tcomb'; import type { EntityText, ThreadEntity } from '../utils/entity-text.js'; import { tShape } from '../utils/validation-utils.js'; export type NotifTexts = { +merged: string | EntityText, +body: string | EntityText, +title: string | ThreadEntity, +prefix?: string | EntityText, }; export type ResolvedNotifTexts = { +merged: string, +body: string, +title: string, +prefix?: string, }; export const resolvedNotifTextsValidator: TInterface = tShape({ merged: t.String, body: t.String, title: t.String, prefix: t.maybe(t.String), }); export type SenderDeviceDescriptor = | { +keyserverID: string } | { +senderDeviceID: string }; export const senderDeviceDescriptorValidator: TUnion = t.union([ tShape({ keyserverID: t.String }), tShape({ senderDeviceID: t.String }), ]); +// Web notifs types export type PlainTextWebNotificationPayload = { +body: string, +prefix?: string, +title: string, +unreadCount: number, +threadID: string, +encryptionFailed?: '1', }; export type PlainTextWebNotification = $ReadOnly<{ +id: string, ...PlainTextWebNotificationPayload, }>; export type EncryptedWebNotification = $ReadOnly<{ ...SenderDeviceDescriptor, +id: string, +encryptedPayload: string, +type: '0' | '1', }>; export type WebNotification = | PlainTextWebNotification | EncryptedWebNotification; +// WNS notifs types export type PlainTextWNSNotification = { +body: string, +prefix?: string, +title: string, +unreadCount: number, +threadID: string, +encryptionFailed?: '1', }; export type EncryptedWNSNotification = $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string, +type: '0' | '1', }>; export type WNSNotification = | PlainTextWNSNotification | EncryptedWNSNotification; +// Android notifs types export type AndroidVisualNotificationPayloadBase = $ReadOnly<{ +badge?: string, +body: string, +title: string, +prefix?: string, +threadID: string, - +collapseKey?: string, + +collapseID?: string, +badgeOnly?: '0', +encryptionFailed?: '1', }>; type AndroidSmallVisualNotificationPayload = $ReadOnly<{ ...AndroidVisualNotificationPayloadBase, +messageInfos?: string, }>; type AndroidLargeVisualNotificationPayload = $ReadOnly<{ ...AndroidVisualNotificationPayloadBase, +blobHash: string, +encryptionKey: string, }>; export type AndroidVisualNotificationPayload = | AndroidSmallVisualNotificationPayload | AndroidLargeVisualNotificationPayload; type EncryptedThinThreadPayload = { +keyserverID: string, +encryptedPayload: string, +type: '0' | '1', }; type EncryptedThickThreadPayload = { +senderDeviceID: string, +encryptedPayload: string, +type: '0' | '1', }; export type AndroidVisualNotification = { +data: $ReadOnly<{ +id?: string, ... | AndroidSmallVisualNotificationPayload | AndroidLargeVisualNotificationPayload | EncryptedThinThreadPayload | EncryptedThickThreadPayload, }>, }; type AndroidThinThreadRescindPayload = { +badge: string, +rescind: 'true', +rescindID?: string, +setUnreadStatus: 'true', +threadID: string, +encryptionFailed?: string, }; type AndroidThickThreadRescindPayload = { +rescind: 'true', +setUnreadStatus: 'true', +threadID: string, +encryptionFailed?: string, }; export type AndroidNotificationRescind = { +data: | AndroidThinThreadRescindPayload | AndroidThickThreadRescindPayload | EncryptedThinThreadPayload | EncryptedThickThreadPayload, }; type AndroidKeyserverBadgeOnlyPayload = { +badge: string, +badgeOnly: '1', +encryptionFailed?: string, }; type AndroidThickThreadBadgeOnlyPayload = { +threadID: string, +badgeOnly: '1', +encryptionFailed?: string, }; export type AndroidBadgeOnlyNotification = { +data: | AndroidKeyserverBadgeOnlyPayload | AndroidThickThreadBadgeOnlyPayload | EncryptedThinThreadPayload | EncryptedThickThreadPayload, }; type AndroidNotificationWithPriority = | { +notification: AndroidVisualNotification, +priority: 'high', } | { +notification: AndroidBadgeOnlyNotification | AndroidNotificationRescind, +priority: 'normal', }; +// APNs notifs types +export type APNsNotificationTopic = + | 'app.comm.macos' + | 'app.comm' + | 'org.squadcal.app'; + +export type APNsNotificationHeaders = { + +'apns-priority'?: 1 | 5 | 10, + +'apns-id'?: string, + +'apns-expiration'?: number, + +'apns-topic': APNsNotificationTopic, + +'apns-collapse-id'?: string, + +'apns-push-type': 'background' | 'alert' | 'voip', +}; + +export type EncryptedAPNsSilentNotification = $ReadOnly<{ + ...SenderDeviceDescriptor, + +headers: APNsNotificationHeaders, + +encryptedPayload: string, + +type: '1' | '0', + +aps: { +'mutable-content': number, +'alert'?: { body: 'ENCRYPTED' } }, +}>; + +export type EncryptedAPNsVisualNotification = $ReadOnly<{ + ...EncryptedAPNsSilentNotification, + +id: string, +}>; + +type APNsVisualNotificationPayloadBase = { + +aps: { + +'badge'?: string | number, + +'alert'?: string | { +body?: string, ... }, + +'thread-id': string, + +'mutable-content'?: number, + +'sound'?: string, + }, + +body: string, + +title: string, + +prefix?: string, + +threadID: string, + +collapseID?: string, + +encryptionFailed?: '1', +}; + +type APNsSmallVisualNotificationPayload = $ReadOnly<{ + ...APNsVisualNotificationPayloadBase, + +messageInfos?: string, +}>; + +type APNsLargeVisualNotificationPayload = $ReadOnly<{ + ...APNsVisualNotificationPayloadBase, + +blobHash: string, + +encryptionKey: string, +}>; + +export type APNsVisualNotification = + | $ReadOnly<{ + +headers: APNsNotificationHeaders, + +id: string, + ... + | APNsSmallVisualNotificationPayload + | APNsLargeVisualNotificationPayload, + }> + | EncryptedAPNsVisualNotification; + +type APNsLegacyRescindPayload = { + +backgroundNotifType: 'CLEAR', + +notificationId: string, + +setUnreadStatus: true, + +threadID: string, + +aps: { + +'badge': string | number, + +'content-available': number, + }, +}; + +type APNsKeyserverRescindPayload = { + +backgroundNotifType: 'CLEAR', + +notificationId: string, + +setUnreadStatus: true, + +threadID: string, + +aps: { + +'badge': string | number, + +'mutable-content': number, + }, +}; + +type APNsThickThreadRescindPayload = { + +backgroundNotifType: 'CLEAR', + +setUnreadStatus: true, + +threadID: string, + +aps: { + +'mutable-content': number, + }, +}; + +export type APNsNotificationRescind = + | $ReadOnly<{ + +headers: APNsNotificationHeaders, + +encryptionFailed?: '1', + ... + | APNsLegacyRescindPayload + | APNsKeyserverRescindPayload + | APNsThickThreadRescindPayload, + }> + | EncryptedAPNsSilentNotification; + +type APNsLegacyBadgeOnlyNotification = { + +aps: { + +badge: string | number, + }, +}; + +type APNsKeyserverBadgeOnlyNotification = { + +aps: { + +'badge': string | number, + +'mutable-content': number, + }, +}; + +type APNsThickThreadBadgeOnlyNotification = { + +aps: { + +'mutable-content': number, + }, + +threadID: string, +}; + +export type APNsBadgeOnlyNotification = + | $ReadOnly<{ + +headers: APNsNotificationHeaders, + +encryptionFailed?: '1', + ... + | APNsLegacyBadgeOnlyNotification + | APNsKeyserverBadgeOnlyNotification + | APNsThickThreadBadgeOnlyNotification, + }> + | EncryptedAPNsSilentNotification; + +export type APNsNotification = + | APNsVisualNotification + | APNsNotificationRescind + | APNsBadgeOnlyNotification; + +export type TargetedAPNsNotification = { + +notification: APNsNotification, + +deliveryID: string, + +encryptedPayloadHash?: string, + +encryptionOrder?: number, +}; + export type TargetedAndroidNotification = $ReadOnly<{ ...AndroidNotificationWithPriority, +deliveryID: string, +encryptionOrder?: number, }>; export type TargetedWebNotification = { +notification: WebNotification, +deliveryID: string, +encryptionOrder?: number, }; export type TargetedWNSNotification = { +notification: WNSNotification, +deliveryID: string, +encryptionOrder?: number, }; export type NotificationTargetDevice = { +cryptoID: string, +deliveryID: string, +blobHolder?: string, }; export type EncryptedNotifUtilsAPI = { +encryptSerializedNotifPayload: ( cryptoID: string, unencryptedPayload: string, encryptedPayloadSizeValidator?: ( encryptedPayload: string, type: '1' | '0', ) => boolean, ) => Promise<{ +encryptedData: EncryptResult, +sizeLimitViolated?: boolean, +encryptionOrder?: number, }>, +uploadLargeNotifPayload: ( payload: string, numberOfHolders: number, ) => Promise< | { +blobHolders: $ReadOnlyArray, +blobHash: string, +encryptionKey: string, } | { +blobUploadError: string }, >, +getNotifByteSize: (serializedNotification: string) => number, + +getEncryptedNotifHash: (serializedNotification: string) => string, };