diff --git a/keyserver/src/push/crypto.js b/keyserver/src/push/crypto.js index d4a5682f6..c4c1b6aae 100644 --- a/keyserver/src/push/crypto.js +++ b/keyserver/src/push/crypto.js @@ -1,320 +1,369 @@ // @flow import apn from '@parse/node-apn'; import invariant from 'invariant'; import _cloneDeep from 'lodash/fp/cloneDeep.js'; +import type { + PlainTextWebNotification, + WebNotification, +} from 'lib/types/notif-types.js'; + import type { AndroidNotification, AndroidNotificationPayload, AndroidNotificationRescind, NotificationTargetDevice, } from './types.js'; import { encryptAndUpdateOlmSession } from '../updaters/olm-session-updater.js'; import { getOlmUtility } from '../utils/olm-utils.js'; async function encryptIOSNotification( cookieID: string, notification: apn.Notification, codeVersion?: ?number, notificationSizeValidator?: apn.Notification => boolean, ): Promise<{ +notification: apn.Notification, +payloadSizeExceeded: boolean, +encryptedPayloadHash?: string, }> { invariant( !notification.collapseId, 'Collapsible notifications encryption currently not implemented', ); const encryptedNotification = new apn.Notification(); encryptedNotification.id = notification.id; encryptedNotification.payload.id = notification.id; encryptedNotification.topic = notification.topic; encryptedNotification.sound = notification.aps.sound; encryptedNotification.pushType = 'alert'; encryptedNotification.mutableContent = true; const { id, ...payloadSansId } = notification.payload; const unencryptedPayload = { ...payloadSansId, badge: notification.aps.badge.toString(), merged: notification.body, }; try { const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); let dbPersistCondition; if (notificationSizeValidator) { dbPersistCondition = ({ serializedPayload }) => { const notifCopy = _cloneDeep(encryptedNotification); notifCopy.payload.encryptedPayload = serializedPayload.body; return notificationSizeValidator(notifCopy); }; } const { encryptedMessages: { serializedPayload }, dbPersistConditionViolated, } = await encryptAndUpdateOlmSession( cookieID, 'notifications', { serializedPayload: unencryptedSerializedPayload, }, dbPersistCondition, ); encryptedNotification.payload.encryptedPayload = serializedPayload.body; if (codeVersion && codeVersion >= 254 && codeVersion % 2 === 0) { encryptedNotification.aps = { alert: { body: 'ENCRYPTED' }, ...encryptedNotification.aps, }; } const encryptedPayloadHash = getOlmUtility().sha256(serializedPayload.body); return { notification: encryptedNotification, payloadSizeExceeded: !!dbPersistConditionViolated, encryptedPayloadHash, }; } catch (e) { console.log('Notification encryption failed: ' + e); encryptedNotification.body = notification.body; encryptedNotification.threadId = notification.payload.threadID; invariant( typeof notification.aps.badge === 'number', 'Unencrypted notification must have badge as a number', ); encryptedNotification.badge = notification.aps.badge; encryptedNotification.payload = { ...encryptedNotification.payload, ...notification.payload, encryptionFailed: 1, }; return { notification: encryptedNotification, payloadSizeExceeded: notificationSizeValidator ? notificationSizeValidator(_cloneDeep(encryptedNotification)) : false, }; } } async function encryptAndroidNotificationPayload( cookieID: string, unencryptedPayload: T, payloadSizeValidator?: (T | { +encryptedPayload: string }) => boolean, ): Promise<{ +resultPayload: T | { +encryptedPayload: string }, +payloadSizeExceeded: boolean, }> { try { const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); if (!unencryptedSerializedPayload) { return { resultPayload: unencryptedPayload, payloadSizeExceeded: payloadSizeValidator ? payloadSizeValidator(unencryptedPayload) : false, }; } let dbPersistCondition; if (payloadSizeValidator) { dbPersistCondition = ({ serializedPayload }) => payloadSizeValidator({ encryptedPayload: serializedPayload.body }); } const { encryptedMessages: { serializedPayload }, dbPersistConditionViolated, } = await encryptAndUpdateOlmSession( cookieID, 'notifications', { serializedPayload: unencryptedSerializedPayload, }, dbPersistCondition, ); return { resultPayload: { encryptedPayload: serializedPayload.body }, payloadSizeExceeded: !!dbPersistConditionViolated, }; } catch (e) { console.log('Notification encryption failed: ' + e); const resultPayload = { encryptionFailed: '1', ...unencryptedPayload, }; return { resultPayload, payloadSizeExceeded: payloadSizeValidator ? payloadSizeValidator(resultPayload) : false, }; } } async function encryptAndroidNotification( cookieID: string, notification: AndroidNotification, notificationSizeValidator?: AndroidNotification => boolean, ): Promise<{ +notification: AndroidNotification, +payloadSizeExceeded: boolean, }> { const { id, badgeOnly, ...unencryptedPayload } = notification.data; let payloadSizeValidator; if (notificationSizeValidator) { payloadSizeValidator = ( payload: AndroidNotificationPayload | { +encryptedPayload: string }, ) => { return notificationSizeValidator({ data: { id, badgeOnly, ...payload } }); }; } const { resultPayload, payloadSizeExceeded } = await encryptAndroidNotificationPayload( cookieID, unencryptedPayload, payloadSizeValidator, ); return { notification: { data: { id, badgeOnly, ...resultPayload, }, }, payloadSizeExceeded, }; } async function encryptAndroidNotificationRescind( cookieID: string, notification: AndroidNotificationRescind, ): Promise { // We don't validate payload size for rescind // since they are expected to be small and // never exceed any FCM limit const { resultPayload } = await encryptAndroidNotificationPayload( cookieID, notification.data, ); return { data: resultPayload, }; } +async function encryptWebNotification( + cookieID: string, + notification: PlainTextWebNotification, +): Promise { + const { id, ...payloadSansId } = notification; + const unencryptedSerializedPayload = JSON.stringify(payloadSansId); + + try { + const { + encryptedMessages: { serializedPayload }, + } = await encryptAndUpdateOlmSession(cookieID, 'notifications', { + serializedPayload: unencryptedSerializedPayload, + }); + + return { id, encryptedPayload: serializedPayload.body }; + } catch (e) { + console.log('Notification encryption failed: ' + e); + return { + id, + encryptionFailed: '1', + ...payloadSansId, + }; + } +} + function prepareEncryptedIOSNotifications( devices: $ReadOnlyArray, notification: apn.Notification, codeVersion?: ?number, notificationSizeValidator?: apn.Notification => boolean, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: apn.Notification, +payloadSizeExceeded: boolean, +encryptedPayloadHash?: string, }>, > { const notificationPromises = devices.map( async ({ cookieID, deviceToken }) => { const notif = await encryptIOSNotification( cookieID, notification, codeVersion, notificationSizeValidator, ); return { cookieID, deviceToken, ...notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedIOSNotificationRescind( devices: $ReadOnlyArray, notification: apn.Notification, codeVersion?: ?number, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: apn.Notification, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const { notification: notif } = await encryptIOSNotification( cookieID, notification, codeVersion, ); return { deviceToken, cookieID, notification: notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedAndroidNotifications( devices: $ReadOnlyArray, notification: AndroidNotification, notificationSizeValidator?: (notification: AndroidNotification) => boolean, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: AndroidNotification, +payloadSizeExceeded: boolean, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const notif = await encryptAndroidNotification( cookieID, notification, notificationSizeValidator, ); return { deviceToken, cookieID, ...notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedAndroidNotificationRescinds( devices: $ReadOnlyArray, notification: AndroidNotificationRescind, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: AndroidNotificationRescind, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const notif = await encryptAndroidNotificationRescind( cookieID, notification, ); return { deviceToken, cookieID, notification: notif }; }, ); return Promise.all(notificationPromises); } +function prepareEncryptedWebNotifications( + devices: $ReadOnlyArray, + notification: PlainTextWebNotification, +): Promise< + $ReadOnlyArray<{ + +deviceToken: string, + +notification: WebNotification, + }>, +> { + const notificationPromises = devices.map( + async ({ deviceToken, cookieID }) => { + const notif = await encryptWebNotification(cookieID, notification); + return { notification: notif, deviceToken }; + }, + ); + return Promise.all(notificationPromises); +} + export { prepareEncryptedIOSNotifications, prepareEncryptedIOSNotificationRescind, prepareEncryptedAndroidNotifications, prepareEncryptedAndroidNotificationRescinds, + prepareEncryptedWebNotifications, }; diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js index ae9017de8..5106bb5d8 100644 --- a/keyserver/src/push/send.js +++ b/keyserver/src/push/send.js @@ -1,1563 +1,1586 @@ // @flow import apn from '@parse/node-apn'; import type { ResponseFailure } from '@parse/node-apn'; import invariant from 'invariant'; import _cloneDeep from 'lodash/fp/cloneDeep.js'; import _flow from 'lodash/fp/flow.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _pickBy from 'lodash/fp/pickBy.js'; import t from 'tcomb'; import uuidv4 from 'uuid/v4.js'; import { oldValidUsernameRegex } from 'lib/shared/account-utils.js'; import { isUserMentioned } from 'lib/shared/mention-utils.js'; import { createMessageInfo, sortMessageInfoList, shimUnsupportedRawMessageInfos, } from 'lib/shared/message-utils.js'; import { messageSpecs } from 'lib/shared/messages/message-specs.js'; import { notifTextsForMessageInfo } from 'lib/shared/notif-utils.js'; +import { isStaff } from 'lib/shared/staff-utils.js'; import { rawThreadInfoFromServerThreadInfo, threadInfoFromRawThreadInfo, } from 'lib/shared/thread-utils.js'; +import { + NEXT_CODE_VERSION, + hasMinCodeVersion, +} from 'lib/shared/version-utils.js'; import type { Platform, PlatformDetails } from 'lib/types/device-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, type MessageData, } from 'lib/types/message-types.js'; import { rawMessageInfoValidator } from 'lib/types/message-types.js'; import type { - WebNotification, WNSNotification, ResolvedNotifTexts, } from 'lib/types/notif-types.js'; import { resolvedNotifTextsValidator } from 'lib/types/notif-types.js'; import type { ServerThreadInfo, ThreadInfo } 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 { isDev } from 'lib/utils/dev-utils.js'; import { promiseAll } from 'lib/utils/promises.js'; import { tID, tPlatformDetails, tShape } from 'lib/utils/validation-utils.js'; import { prepareEncryptedIOSNotifications, prepareEncryptedAndroidNotifications, + prepareEncryptedWebNotifications, } from './crypto.js'; import { getAPNsNotificationTopic } from './providers.js'; import { rescindPushNotifs } from './rescind.js'; import type { NotificationTargetDevice, TargetedAPNsNotification, TargetedAndroidNotification, + TargetedWebNotification, } from './types.js'; import { apnPush, fcmPush, getUnreadCounts, apnMaxNotificationPayloadByteSize, fcmMaxNotificationPayloadByteSize, wnsMaxNotificationPayloadByteSize, webPush, wnsPush, type WebPushError, type WNSPushError, } from './utils.js'; import createIDs from '../creators/id-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL, mergeOrConditions } 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 { getENSNames } from '../utils/ens-cache.js'; import { validateOutput } from '../utils/validation-utils.js'; export type Device = { +platform: Platform, +deviceToken: string, +cookieID: string, +codeVersion: ?number, +stateVersion: ?number, }; 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 [ unreadCounts, { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }, dbIDs, ] = await Promise.all([ getUnreadCounts(Object.keys(pushInfo)), fetchInfos(pushInfo), createDBIDs(pushInfo), ]); const deliveryPromises = []; const notifications: Map = new Map(); for (const userID in usersToCollapsableNotifInfo) { const threadInfos = _flow( _mapValues((serverThreadInfo: ServerThreadInfo) => { const rawThreadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, userID, ); if (!rawThreadInfo) { return null; } return threadInfoFromRawThreadInfo(rawThreadInfo, userID, userInfos); }), _pickBy(threadInfo => threadInfo), )(serverThreadInfos); for (const notifInfo of usersToCollapsableNotifInfo[userID]) { deliveryPromises.push( sendPushNotif({ notifInfo, userID, pushUserInfo: pushInfo[userID], unreadCount: unreadCounts[userID], threadInfos, userInfos, dbIDs, rowsToSave: notifications, }), ); } } const deliveryResults = await Promise.all(deliveryPromises); const flattenedDeliveryResults = []; for (const innerDeliveryResults of deliveryResults) { if (!innerDeliveryResults) { continue; } for (const deliveryResult of innerDeliveryResults) { flattenedDeliveryResults.push(deliveryResult); } } 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(flattenedDeliveryResults, notifications, true), ]); } async function sendPushNotif(input: { notifInfo: CollapsableNotifInfo, userID: string, pushUserInfo: PushUserInfo, unreadCount: number, threadInfos: { +[threadID: string]: ThreadInfo }, userInfos: { +[userID: string]: GlobalUserInfo }, dbIDs: string[], // mutable rowsToSave: Map, // mutable }): Promise { const { 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; 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) ); }); if (!updateBadge && !displayBanner && !userWasMentioned) { return null; } const badgeOnly = !displayBanner && !userWasMentioned; const notifTargetUserInfo = { id: userID, username }; const notifTexts = await notifTextsForMessageInfo( allMessageInfos, threadInfo, parentThreadInfo, notifTargetUserInfo, getENSNames, ); 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 deliveryPromises = []; 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 deliveryPromise: Promise = (async () => { const targetedNotifications = await prepareAPNsNotification( { notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount, platformDetails, }, devices, ); return await sendAPNsNotification('ios', targetedNotifications, { ...notificationInfo, codeVersion, stateVersion, }); })(); deliveryPromises.push(deliveryPromise); } } 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 deliveryPromise: Promise = (async () => { const targetedNotifications = await prepareAndroidNotification( { notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount, platformDetails, dbID, }, devices, ); return await sendAndroidNotification(targetedNotifications, { ...notificationInfo, codeVersion, stateVersion, }); })(); deliveryPromises.push(deliveryPromise); } } 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 deliveryPromise: Promise = (async () => { - const notification = await prepareWebNotification({ - notifTexts, - threadID: threadInfo.id, - unreadCount, - platformDetails, - }); - const deviceTokens = devices.map(({ deviceToken }) => deviceToken); - return await sendWebNotification(notification, deviceTokens, { + const targetedNotifications = await prepareWebNotification( + userID, + { + notifTexts, + threadID: threadInfo.id, + unreadCount, + platformDetails, + }, + devices, + ); + + return await sendWebNotifications(targetedNotifications, { ...notificationInfo, codeVersion, stateVersion, }); })(); deliveryPromises.push(deliveryPromise); } } const macosVersionsToTokens = byPlatform.get('macos'); if (macosVersionsToTokens) { for (const [versionKey, devices] of macosVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'macos', codeVersion, stateVersion, }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const deliveryPromise: Promise = (async () => { const targetedNotifications = await prepareAPNsNotification( { notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount, platformDetails, }, devices, ); return await sendAPNsNotification('macos', targetedNotifications, { ...notificationInfo, codeVersion, stateVersion, }); })(); deliveryPromises.push(deliveryPromise); } } const windowsVersionsToTokens = byPlatform.get('windows'); if (windowsVersionsToTokens) { for (const [versionKey, devices] of windowsVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'windows', codeVersion, stateVersion, }; const deliveryPromise: Promise = (async () => { const notification = await prepareWNSNotification({ notifTexts, threadID: threadInfo.id, unreadCount, platformDetails, }); const deviceTokens = devices.map(({ deviceToken }) => deviceToken); return await sendWNSNotification(notification, deviceTokens, { ...notificationInfo, codeVersion, stateVersion, }); })(); deliveryPromises.push(deliveryPromise); } } 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 }], }); } return await Promise.all(deliveryPromises); } 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 = []; 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); } } } const promises = {}; // These threadInfos won't have currentUser set promises.threadResult = fetchServerThreadInfos({ threadIDs }); if (threadWithChangedNamesToMessages.size > 0) { 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 `); promises.oldNames = dbQuery(oldNameQuery); } const { threadResult, oldNames } = await promiseAll(promises); const serverThreadInfos = threadResult.threadInfos; if (oldNames) { const [result] = oldNames; for (const row of result) { const threadID = row.thread.toString(); 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 }; const versionKeyRegex: RegExp = new RegExp(/^-?\d+\|-?\d+$/); function versionKeyToString(versionKey: VersionKey): string { return `${versionKey.codeVersion}|${versionKey.stateVersion}`; } function stringToVersionKey(versionKeyString: string): VersionKey { invariant( versionKeyRegex.test(versionKeyString), 'should pass correct version key string', ); const [codeVersion, stateVersion] = versionKeyString.split('|').map(Number); return { codeVersion, stateVersion }; } function getDevicesByPlatform( devices: $ReadOnlyArray, ): Map>> { const byPlatform = new 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.platform !== 'windows' && device.platform !== 'macos' ? device.codeVersion : -1; const stateVersion: number = device.stateVersion ?? -1; const versionKey = versionKeyToString({ codeVersion, stateVersion, }); let innerMostArray = innerMap.get(versionKey); if (!innerMostArray) { innerMostArray = []; innerMap.set(versionKey, innerMostArray); } innerMostArray.push({ cookieID: device.cookieID, deviceToken: device.deviceToken, }); } return byPlatform; } type APNsNotifInputData = { +notifTexts: ResolvedNotifTexts, +newRawMessageInfos: RawMessageInfo[], +threadID: string, +collapseKey: ?string, +badgeOnly: boolean, +unreadCount: number, +platformDetails: PlatformDetails, }; const apnsNotifInputDataValidator = tShape({ 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: APNsNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = validateOutput( inputData.platformDetails, apnsNotifInputDataValidator, inputData, ); const { notifTexts, newRawMessageInfos, threadID, collapseKey, badgeOnly, unreadCount, platformDetails, } = convertedData; const canDecryptNonCollapsibleTextNotifs = platformDetails.codeVersion && platformDetails.codeVersion > 222; const isNonCollapsibleTextNotification = newRawMessageInfos.every( newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, ) && !collapseKey; const canDecryptAllNotifTypes = platformDetails.codeVersion && platformDetails.codeVersion >= 267; const shouldBeEncrypted = platformDetails.platform === 'ios' && (canDecryptAllNotifTypes || (isNonCollapsibleTextNotification && canDecryptNonCollapsibleTextNotifs)); 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 && canDecryptAllNotifTypes) { 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 => notif.length() <= apnMaxNotificationPayloadByteSize; if (!shouldBeEncrypted) { const notificationToSend = notificationSizeValidator( _cloneDeep(copyWithMessageInfos), ) ? copyWithMessageInfos : notification; return devices.map(({ deviceToken }) => ({ notification: notificationToSend, deviceToken, })); } const notifsWithMessageInfos = await prepareEncryptedIOSNotifications( devices, copyWithMessageInfos, platformDetails.codeVersion, notificationSizeValidator, ); const devicesWithExcessiveSize = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) .map(({ deviceToken, cookieID }) => ({ deviceToken, cookieID })); if (devicesWithExcessiveSize.length === 0) { return notifsWithMessageInfos.map( ({ notification: notif, deviceToken, encryptedPayloadHash }) => ({ notification: notif, deviceToken, encryptedPayloadHash, }), ); } const notifsWithoutMessageInfos = await prepareEncryptedIOSNotifications( devicesWithExcessiveSize, notification, platformDetails.codeVersion, ); const targetedNotifsWithMessageInfos = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) .map(({ notification: notif, deviceToken, encryptedPayloadHash }) => ({ notification: notif, deviceToken, encryptedPayloadHash, })); const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( ({ notification: notif, deviceToken, encryptedPayloadHash }) => ({ notification: notif, deviceToken, encryptedPayloadHash, }), ); return [ ...targetedNotifsWithMessageInfos, ...targetedNotifsWithoutMessageInfos, ]; } type AndroidNotifInputData = { ...APNsNotifInputData, +dbID: string, }; const androidNotifInputDataValidator = tShape({ ...apnsNotifInputDataValidator.meta.props, dbID: t.String, }); async function prepareAndroidNotification( inputData: AndroidNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = validateOutput( inputData.platformDetails, androidNotifInputDataValidator, inputData, ); const { notifTexts, newRawMessageInfos, threadID, collapseKey, badgeOnly, unreadCount, platformDetails: { codeVersion }, dbID, } = convertedData; const canDecryptNonCollapsibleTextNotifs = codeVersion && codeVersion > 228; const isNonCollapsibleTextNotif = newRawMessageInfos.every( newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, ) && !collapseKey; const canDecryptAllNotifTypes = codeVersion && codeVersion >= 267; const shouldBeEncrypted = canDecryptAllNotifTypes || (canDecryptNonCollapsibleTextNotifs && isNonCollapsibleTextNotif); const { merged, ...rest } = notifTexts; const notification = { data: { badge: unreadCount.toString(), ...rest, threadID, }, }; let notifID; if (collapseKey && canDecryptAllNotifTypes) { notifID = dbID; notification.data = { ...notification.data, collapseKey, }; } else if (collapseKey) { notifID = collapseKey; } else { notifID = dbID; } // The reason we only include `badgeOnly` for newer clients is because older // clients don't know how to parse it. The reason we only include `id` for // newer clients is that if the older clients see that field, they assume // the notif has a full payload, and then crash when trying to parse it. // By skipping `id` we allow old clients to still handle in-app notifs and // badge updating. if (!badgeOnly || (codeVersion && codeVersion >= 69)) { notification.data = { ...notification.data, id: notifID, badgeOnly: badgeOnly ? '1' : '0', }; } const messageInfos = JSON.stringify(newRawMessageInfos); const copyWithMessageInfos = { ...notification, data: { ...notification.data, messageInfos }, }; if (!shouldBeEncrypted) { const notificationToSend = Buffer.byteLength(JSON.stringify(copyWithMessageInfos)) <= fcmMaxNotificationPayloadByteSize ? copyWithMessageInfos : notification; return devices.map(({ deviceToken }) => ({ notification: notificationToSend, deviceToken, })); } const notificationsSizeValidator = notif => { const serializedNotif = JSON.stringify(notif); return ( !serializedNotif || Buffer.byteLength(serializedNotif) <= fcmMaxNotificationPayloadByteSize ); }; const notifsWithMessageInfos = await prepareEncryptedAndroidNotifications( devices, copyWithMessageInfos, notificationsSizeValidator, ); const devicesWithExcessiveSize = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) .map(({ cookieID, deviceToken }) => ({ cookieID, deviceToken })); if (devicesWithExcessiveSize.length === 0) { return notifsWithMessageInfos.map( ({ notification: notif, deviceToken }) => ({ notification: notif, deviceToken, }), ); } const notifsWithoutMessageInfos = await prepareEncryptedAndroidNotifications( devicesWithExcessiveSize, notification, ); const targetedNotifsWithMessageInfos = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) .map(({ notification: notif, deviceToken }) => ({ notification: notif, deviceToken, })); const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( ({ notification: notif, deviceToken }) => ({ notification: notif, deviceToken, }), ); return [ ...targetedNotifsWithMessageInfos, ...targetedNotifsWithoutMessageInfos, ]; } type WebNotifInputData = { +notifTexts: ResolvedNotifTexts, +threadID: string, +unreadCount: number, +platformDetails: PlatformDetails, }; const webNotifInputDataValidator = tShape({ notifTexts: resolvedNotifTextsValidator, threadID: tID, unreadCount: t.Number, platformDetails: tPlatformDetails, }); async function prepareWebNotification( + userID: string, inputData: WebNotifInputData, -): Promise { + devices: $ReadOnlyArray, +): Promise<$ReadOnlyArray> { const convertedData = validateOutput( inputData.platformDetails, webNotifInputDataValidator, inputData, ); const { notifTexts, threadID, unreadCount } = convertedData; const id = uuidv4(); const { merged, ...rest } = notifTexts; const notification = { ...rest, unreadCount, id, threadID, }; - return notification; + + const isStaffOrDev = isStaff(userID) || isDev; + const shouldBeEncrypted = + hasMinCodeVersion(convertedData.platformDetails, { + web: NEXT_CODE_VERSION, + }) && isStaffOrDev; + + if (!shouldBeEncrypted) { + return devices.map(({ deviceToken }) => ({ deviceToken, notification })); + } + + return prepareEncryptedWebNotifications(devices, notification); } type WNSNotifInputData = { +notifTexts: ResolvedNotifTexts, +threadID: string, +unreadCount: number, +platformDetails: PlatformDetails, }; const wnsNotifInputDataValidator = tShape({ notifTexts: resolvedNotifTextsValidator, threadID: tID, unreadCount: t.Number, platformDetails: tPlatformDetails, }); async function prepareWNSNotification( inputData: WNSNotifInputData, ): Promise { const convertedData = validateOutput( inputData.platformDetails, wnsNotifInputDataValidator, inputData, ); const { notifTexts, threadID, unreadCount } = convertedData; const { merged, ...rest } = notifTexts; const notification = { ...rest, unreadCount, threadID, }; if ( Buffer.byteLength(JSON.stringify(notification)) > wnsMaxNotificationPayloadByteSize ) { console.warn('WNS notification exceeds size limit'); } return notification; } 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( ({ deviceToken }) => deviceToken, ); let delivery: APNsDelivery = { source, deviceType: platform, iosID, deviceTokens, codeVersion, stateVersion, }; if (response.errors) { delivery = { ...delivery, errors: response.errors, }; } const deviceTokensToPayloadHash = {}; for (const targetedNotification of targetedNotifications) { if (targetedNotification.encryptedPayloadHash) { deviceTokensToPayloadHash[targetedNotification.deviceToken] = 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( ({ deviceToken }) => deviceToken, ); 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 sendWebNotification( - notification: WebNotification, - deviceTokens: $ReadOnlyArray, +async function sendWebNotifications( + targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion, stateVersion } = notificationInfo; - const response = await webPush({ - notification, - deviceTokens, - }); + const response = await webPush(targetedNotifications); + const deviceTokens = targetedNotifications.map( + ({ deviceToken }) => deviceToken, + ); 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( notification: WNSNotification, deviceTokens: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion, stateVersion } = notificationInfo; const response = await wnsPush({ notification, deviceTokens, }); 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 = []; 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]] = await Promise.all([ getUnreadCounts([userID]), dbQuery(deviceTokenQuery), createIDs('notifications', 1), ]); 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 deliveryPromises = []; 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 deliveryPromise: Promise = (async () => { let targetedNotifications; if (codeVersion > 222) { const notificationsArray = await prepareEncryptedIOSNotifications( deviceInfos, notification, codeVersion, ); targetedNotifications = notificationsArray.map( ({ notification: notif, deviceToken }) => ({ notification: notif, deviceToken, }), ); } else { targetedNotifications = deviceInfos.map(({ deviceToken }) => ({ notification, deviceToken, })); } return await sendAPNsNotification('ios', targetedNotifications, { source, dbID, userID, codeVersion, stateVersion, }); })(); deliveryPromises.push(deliveryPromise); } } const androidVersionsToTokens = byPlatform.get('android'); if (androidVersionsToTokens) { for (const [versionKey, deviceInfos] of androidVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const notificationData = codeVersion < 69 ? { badge: unreadCount.toString() } : { badge: unreadCount.toString(), badgeOnly: '1' }; const notification = { data: notificationData }; const deliveryPromise: Promise = (async () => { let targetedNotifications; if (codeVersion > 222) { const notificationsArray = await prepareEncryptedAndroidNotifications( deviceInfos, notification, ); targetedNotifications = notificationsArray.map( ({ notification: notif, deviceToken }) => ({ notification: notif, deviceToken, }), ); } else { targetedNotifications = deviceInfos.map(({ deviceToken }) => ({ deviceToken, notification, })); } return await sendAndroidNotification(targetedNotifications, { source, dbID, userID, codeVersion, stateVersion, }); })(); deliveryPromises.push(deliveryPromise); } } const macosVersionsToTokens = byPlatform.get('macos'); if (macosVersionsToTokens) { for (const [versionKey, deviceInfos] of macosVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic({ platform: 'macos', codeVersion, stateVersion, }); notification.badge = unreadCount; notification.pushType = 'alert'; const targetedNotifications = deviceInfos.map(({ deviceToken }) => ({ deviceToken, notification, })); deliveryPromises.push( sendAPNsNotification('macos', targetedNotifications, { source, dbID, userID, codeVersion, stateVersion, }), ); } } const deliveryResults = await Promise.all(deliveryPromises); await saveNotifResults(deliveryResults, new Map(), false); } export { sendPushNotifs, sendRescindNotifs, updateBadgeCount }; diff --git a/keyserver/src/push/types.js b/keyserver/src/push/types.js index 641749887..2b0e819cc 100644 --- a/keyserver/src/push/types.js +++ b/keyserver/src/push/types.js @@ -1,61 +1,68 @@ // @flow import apn from '@parse/node-apn'; +import type { WebNotification } from 'lib/types/notif-types.js'; + export type TargetedAPNsNotification = { +notification: apn.Notification, +deviceToken: string, +encryptedPayloadHash?: string, }; type AndroidNotificationPayloadBase = { +badge: string, +body?: string, +title?: string, +prefix?: string, +threadID?: string, +collapseKey?: string, +encryptionFailed?: '1', }; export type AndroidNotificationPayload = | { ...AndroidNotificationPayloadBase, +messageInfos?: string, } | { ...AndroidNotificationPayloadBase, +blobHash: string, +encryptionKey: string, }; export type AndroidNotification = { +data: { +id?: string, +badgeOnly?: string, ...AndroidNotificationPayload | { +encryptedPayload: string }, }, }; export type AndroidNotificationRescind = { +data: | { +badge: string, +rescind: 'true', +rescindID: string, +setUnreadStatus: 'true', +threadID: string, +encryptionFailed?: string, } | { +encryptedPayload: string }, }; export type TargetedAndroidNotification = { +notification: AndroidNotification | AndroidNotificationRescind, +deviceToken: string, }; +export type TargetedWebNotification = { + +notification: WebNotification, + +deviceToken: string, +}; + export type NotificationTargetDevice = { +cookieID: string, +deviceToken: string, }; diff --git a/keyserver/src/push/utils.js b/keyserver/src/push/utils.js index 981c5b599..973eb474e 100644 --- a/keyserver/src/push/utils.js +++ b/keyserver/src/push/utils.js @@ -1,479 +1,478 @@ // @flow import type { ResponseFailure } from '@parse/node-apn'; import crypto from 'crypto'; 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 blobService from 'lib/facts/blob-service.js'; import type { PlatformDetails } from 'lib/types/device-types.js'; -import type { - WebNotification, - WNSNotification, -} from 'lib/types/notif-types.js'; +import type { WNSNotification } from 'lib/types/notif-types.js'; import { threadSubscriptions } from 'lib/types/subscription-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { toBase64URL } from 'lib/utils/base64.js'; import { makeBlobServiceEndpointURL } from 'lib/utils/blob-service.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { getAPNPushProfileForCodeVersion, getFCMPushProfileForCodeVersion, getAPNProvider, getFCMProvider, ensureWebPushInitialized, getWNSToken, } from './providers.js'; import type { TargetedAPNsNotification, TargetedAndroidNotification, + TargetedWebNotification, } from './types.js'; import { dbQuery, SQL } from '../database/database.js'; import { generateKey, encrypt } from '../utils/aes-crypto-utils.js'; const fcmTokenInvalidationErrors = new Set([ 'messaging/registration-token-not-registered', 'messaging/invalid-registration-token', ]); const fcmMaxNotificationPayloadByteSize = 4000; const apnTokenInvalidationErrorCode = 410; const apnBadRequestErrorCode = 400; const apnBadTokenErrorString = 'BadDeviceToken'; const apnMaxNotificationPayloadByteSize = 4096; const webInvalidTokenErrorCodes = [404, 410]; const wnsInvalidTokenErrorCodes = [404, 410]; const wnsMaxNotificationPayloadByteSize = 5000; 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, deviceToken }) => { return apnProvider.send(notification, deviceToken); }), ); const mergedResults = { sent: [], failed: [] }; for (const result of results) { mergedResults.sent.push(...result.sent); mergedResults.failed.push(...result.failed); } const errors = []; const invalidTokens = []; for (const error of mergedResults.failed) { errors.push(error); /* 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 FCMPushResult = { +success?: true, +fcmIDs?: $ReadOnlyArray, +errors?: $ReadOnlyArray, +invalidTokens?: $ReadOnlyArray, }; 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 = { priority: 'high', }; 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 promises = []; for (const { notification, deviceToken } of targetedNotifications) { promises.push( fcmSinglePush(fcmProvider, notification, deviceToken, options), ); } const pushResults = await Promise.all(promises); const errors = []; const ids = []; const invalidTokens = []; for (let i = 0; i < pushResults.length; i++) { const pushResult = pushResults[i]; for (const error of pushResult.errors) { errors.push(error); if (fcmTokenInvalidationErrors.has(error.errorInfo.code)) { invalidTokens.push(targetedNotifications[i].deviceToken); } } for (const id of pushResult.fcmIDs) { ids.push(id); } } const result = {}; 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 }; } async function fcmSinglePush( provider: FirebaseApp, notification: Object, deviceToken: string, options: Object, ) { 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(fcmResult.error); } else if (fcmResult.messageId) { ids.push(fcmResult.messageId); } } return { fcmIDs: ids, errors }; } catch (e) { return { fcmIDs: [], errors: [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 = {}; 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 WebPushResult = { +success?: true, +errors?: $ReadOnlyArray, +invalidTokens?: $ReadOnlyArray, }; -async function webPush({ - notification, - deviceTokens, -}: { - +notification: WebNotification, - +deviceTokens: $ReadOnlyArray, -}): Promise { +async function webPush( + targetedNotifications: $ReadOnlyArray, +): Promise { await ensureWebPushInitialized(); - const notificationString = JSON.stringify(notification); const pushResults = await Promise.all( - deviceTokens.map(async deviceTokenString => { - const deviceToken: PushSubscriptionJSON = JSON.parse(deviceTokenString); - try { - await webpush.sendNotification(deviceToken, notificationString); - } catch (error) { - return { error }; - } - return {}; - }), + targetedNotifications.map( + async ({ notification, deviceToken: deviceTokenString }) => { + const deviceToken: PushSubscriptionJSON = JSON.parse(deviceTokenString); + const notificationString = JSON.stringify(notification); + try { + await webpush.sendNotification(deviceToken, notificationString); + } catch (error) { + return { error }; + } + return {}; + }, + ), ); const errors = []; const invalidTokens = []; + const deviceTokens = targetedNotifications.map( + ({ deviceToken }) => deviceToken, + ); for (let i = 0; i < pushResults.length; i++) { const pushResult = pushResults[i]; if (pushResult.error) { errors.push(pushResult.error); if (webInvalidTokenErrorCodes.includes(pushResult.error.statusCode)) { invalidTokens.push(deviceTokens[i]); } } } const result = {}; 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 WNSPushResult = { +success?: true, +wnsIDs?: $ReadOnlyArray, +errors?: $ReadOnlyArray, +invalidTokens?: $ReadOnlyArray, }; async function wnsPush({ notification, deviceTokens, }: { +notification: WNSNotification, +deviceTokens: $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 notificationString = JSON.stringify(notification); const pushResults = deviceTokens.map(async devicePushURL => { try { return await wnsSinglePush(token, notificationString, devicePushURL); } catch (error) { return { error }; } }); const errors = []; const notifIDs = []; const invalidTokens = []; 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 = {}; 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): Promise< | { +blobHash: string, +encryptionKey: string, } | { +blobUploadError: string }, > { const encryptionKey = await generateKey(); const encryptedPayloadBuffer = Buffer.from( await encrypt(encryptionKey, new TextEncoder().encode(payload)), ); const blobHolder = uuid.v4(); const blobHashBase64 = await crypto .createHash('sha256') .update(encryptedPayloadBuffer) .digest('base64'); const blobHash = toBase64URL(blobHashBase64); const formData = new FormData(); const payloadBlob = new Blob([encryptedPayloadBuffer]); formData.append('blob_hash', blobHash); formData.append('blob_data', payloadBlob); const assignHolderPromise = fetch( makeBlobServiceEndpointURL(blobService.httpEndpoints.ASSIGN_HOLDER), { method: blobService.httpEndpoints.ASSIGN_HOLDER.method, body: JSON.stringify({ holder: blobHolder, blob_hash: blobHash, }), headers: { 'content-type': 'application/json', }, }, ); const uploadHolderPromise = fetch( makeBlobServiceEndpointURL(blobService.httpEndpoints.UPLOAD_BLOB), { method: blobService.httpEndpoints.UPLOAD_BLOB.method, body: formData, }, ); try { const [assignHolderResponse, uploadBlobResponse] = await Promise.all([ assignHolderPromise, uploadHolderPromise, ]); if (!assignHolderResponse.ok) { const { status, statusText } = assignHolderResponse; return { blobUploadError: `Holder assignment failed with HTTP ${status}: ${statusText}`, }; } if (!uploadBlobResponse.ok) { const { status, statusText } = uploadBlobResponse; return { blobUploadError: `Payload upload failed with HTTP ${status}: ${statusText}`, }; } } catch (e) { return { blobUploadError: `Payload upload failed with: ${ getMessageForException(e) ?? 'unknown error' }`, }; } const encryptionKeyString = Buffer.from(encryptionKey).toString('base64'); return { blobHash, encryptionKey: encryptionKeyString, }; } export { apnPush, blobServiceUpload, fcmPush, webPush, wnsPush, getUnreadCounts, apnMaxNotificationPayloadByteSize, fcmMaxNotificationPayloadByteSize, wnsMaxNotificationPayloadByteSize, }; diff --git a/lib/types/notif-types.js b/lib/types/notif-types.js index d8f1414cd..647773c75 100644 --- a/lib/types/notif-types.js +++ b/lib/types/notif-types.js @@ -1,44 +1,58 @@ // @flow import t, { type TInterface } 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 WebNotification = { +export type PlainTextWebNotificationPayload = { +body: string, +prefix?: string, +title: string, +unreadCount: number, - +id: string, +threadID: string, + +encryptionFailed?: '1', +}; + +export type PlainTextWebNotification = { + +id: string, + ...PlainTextWebNotificationPayload, }; +export type EncryptedWebNotification = { + +id: string, + +encryptedPayload: string, +}; + +export type WebNotification = + | PlainTextWebNotification + | EncryptedWebNotification; + export type WNSNotification = { +body: string, +prefix?: string, +title: string, +unreadCount: number, +threadID: string, }; diff --git a/web/push-notif/service-worker.js b/web/push-notif/service-worker.js index 612f6bcbb..87333483e 100644 --- a/web/push-notif/service-worker.js +++ b/web/push-notif/service-worker.js @@ -1,80 +1,80 @@ // @flow -import type { WebNotification } from 'lib/types/notif-types.js'; +import type { PlainTextWebNotification } from 'lib/types/notif-types.js'; import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; declare class PushMessageData { json(): Object; } declare class PushEvent extends ExtendableEvent { +data: PushMessageData; } declare var clients: Clients; declare function skipWaiting(): Promise; self.addEventListener('install', () => { skipWaiting(); }); self.addEventListener('activate', (event: ExtendableEvent) => { event.waitUntil(clients.claim()); }); self.addEventListener('push', (event: PushEvent) => { - const data: WebNotification = event.data.json(); + const data: PlainTextWebNotification = event.data.json(); event.waitUntil( (async () => { let body = data.body; if (data.prefix) { body = `${data.prefix} ${body}`; } await self.registration.showNotification(data.title, { body, badge: 'https://web.comm.app/favicon.ico', icon: 'https://web.comm.app/favicon.ico', tag: data.id, data: { unreadCount: data.unreadCount, threadID: data.threadID, }, }); })(), ); }); self.addEventListener('notificationclick', (event: NotificationEvent) => { event.notification.close(); event.waitUntil( (async () => { const clientList: Array = (await clients.matchAll({ type: 'window', }): any); const selectedClient = clientList.find(client => client.focused) ?? clientList[0]; const threadID = convertNonPendingIDToNewSchema( event.notification.data.threadID, ashoatKeyserverID, ); if (selectedClient) { if (!selectedClient.focused) { await selectedClient.focus(); } selectedClient.postMessage({ targetThreadID: threadID, }); } else { const url = (process.env.NODE_ENV === 'production' ? 'https://web.comm.app' : 'http://localhost:3000/webapp') + `/chat/thread/${threadID}/`; clients.openWindow(url); } })(), ); });