Changeset View
Standalone View
keyserver/src/push/send.js
Show All 35 Lines | import type { | ||||
ResolvedNotifTexts, | ResolvedNotifTexts, | ||||
} from 'lib/types/notif-types.js'; | } from 'lib/types/notif-types.js'; | ||||
import { resolvedNotifTextsValidator } from 'lib/types/notif-types.js'; | import { resolvedNotifTextsValidator } from 'lib/types/notif-types.js'; | ||||
import type { ServerThreadInfo } from 'lib/types/thread-types.js'; | import type { ServerThreadInfo } from 'lib/types/thread-types.js'; | ||||
import { updateTypes } from 'lib/types/update-types-enum.js'; | import { updateTypes } from 'lib/types/update-types-enum.js'; | ||||
import { promiseAll } from 'lib/utils/promises.js'; | import { promiseAll } from 'lib/utils/promises.js'; | ||||
import { tID, tPlatformDetails, tShape } from 'lib/utils/validation-utils.js'; | import { tID, tPlatformDetails, tShape } from 'lib/utils/validation-utils.js'; | ||||
import { prepareEncryptedIOSNotifications } from './crypto.js'; | import { | ||||
prepareEncryptedIOSNotifications, | |||||
prepareEncryptedAndroidNotifications, | |||||
} from './crypto.js'; | |||||
import { getAPNsNotificationTopic } from './providers.js'; | import { getAPNsNotificationTopic } from './providers.js'; | ||||
import { rescindPushNotifs } from './rescind.js'; | import { rescindPushNotifs } from './rescind.js'; | ||||
import type { AndroidNotification } from './types.js'; | |||||
import { | import { | ||||
apnPush, | apnPush, | ||||
fcmPush, | fcmPush, | ||||
getUnreadCounts, | getUnreadCounts, | ||||
apnMaxNotificationPayloadByteSize, | apnMaxNotificationPayloadByteSize, | ||||
fcmMaxNotificationPayloadByteSize, | fcmMaxNotificationPayloadByteSize, | ||||
wnsMaxNotificationPayloadByteSize, | wnsMaxNotificationPayloadByteSize, | ||||
webPush, | webPush, | ||||
▲ Show 20 Lines • Show All 175 Lines • ▼ Show 20 Lines | for (const notifInfo of usersToCollapsableNotifInfo[userID]) { | ||||
}, | }, | ||||
); | ); | ||||
})(); | })(); | ||||
deliveryPromises.push(deliveryPromise); | deliveryPromises.push(deliveryPromise); | ||||
} | } | ||||
} | } | ||||
const androidVersionsToTokens = byPlatform.get('android'); | const androidVersionsToTokens = byPlatform.get('android'); | ||||
if (androidVersionsToTokens) { | if (androidVersionsToTokens) { | ||||
for (const [codeVersion, { deviceTokens }] of androidVersionsToTokens) { | for (const [ | ||||
codeVersion, | |||||
{ cookieIDs, deviceTokens }, | |||||
] of androidVersionsToTokens) { | |||||
const platformDetails = { platform: 'android', codeVersion }; | const platformDetails = { platform: 'android', codeVersion }; | ||||
const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( | const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( | ||||
newRawMessageInfos, | newRawMessageInfos, | ||||
platformDetails, | platformDetails, | ||||
); | ); | ||||
const deliveryPromise = (async () => { | const deliveryPromise = (async () => { | ||||
const notification = await prepareAndroidNotification({ | const notificationsArray = await prepareAndroidNotification( | ||||
{ | |||||
notifTexts, | notifTexts, | ||||
newRawMessageInfos: shimmedNewRawMessageInfos, | newRawMessageInfos: shimmedNewRawMessageInfos, | ||||
threadID: threadInfo.id, | threadID: threadInfo.id, | ||||
collapseKey: notifInfo.collapseKey, | collapseKey: notifInfo.collapseKey, | ||||
badgeOnly, | badgeOnly, | ||||
unreadCount: unreadCounts[userID], | unreadCount: unreadCounts[userID], | ||||
platformDetails, | platformDetails, | ||||
dbID, | dbID, | ||||
}); | }, | ||||
[...cookieIDs], | |||||
); | |||||
return await sendAndroidNotification( | return await sendAndroidNotification( | ||||
notification, | notificationsArray, | ||||
[...deviceTokens], | [...deviceTokens], | ||||
{ | { | ||||
...notificationInfo, | ...notificationInfo, | ||||
codeVersion, | codeVersion, | ||||
}, | }, | ||||
); | ); | ||||
})(); | })(); | ||||
deliveryPromises.push(deliveryPromise); | deliveryPromises.push(deliveryPromise); | ||||
▲ Show 20 Lines • Show All 485 Lines • ▼ Show 20 Lines | type AndroidNotifInputData = { | ||||
+dbID: string, | +dbID: string, | ||||
}; | }; | ||||
const androidNotifInputDataValidator = tShape<AndroidNotifInputData>({ | const androidNotifInputDataValidator = tShape<AndroidNotifInputData>({ | ||||
...apnsNotifInputDataValidator.meta.props, | ...apnsNotifInputDataValidator.meta.props, | ||||
dbID: t.String, | dbID: t.String, | ||||
}); | }); | ||||
async function prepareAndroidNotification( | async function prepareAndroidNotification( | ||||
inputData: AndroidNotifInputData, | inputData: AndroidNotifInputData, | ||||
): Promise<Object> { | cookieIDs?: $ReadOnlyArray<string>, | ||||
): Promise<Array<AndroidNotification>> { | |||||
const convertedData = validateOutput( | const convertedData = validateOutput( | ||||
inputData.platformDetails, | inputData.platformDetails, | ||||
androidNotifInputDataValidator, | androidNotifInputDataValidator, | ||||
inputData, | inputData, | ||||
); | ); | ||||
const { | const { | ||||
notifTexts, | notifTexts, | ||||
newRawMessageInfos, | newRawMessageInfos, | ||||
threadID, | threadID, | ||||
collapseKey, | collapseKey, | ||||
badgeOnly, | badgeOnly, | ||||
unreadCount, | unreadCount, | ||||
platformDetails: { codeVersion }, | platformDetails: { codeVersion }, | ||||
dbID, | dbID, | ||||
} = convertedData; | } = convertedData; | ||||
const isTextNotification = newRawMessageInfos.every( | |||||
newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, | |||||
); | |||||
const shouldBeEncrypted = | |||||
isTextNotification && !collapseKey && codeVersion && codeVersion > 0; | |||||
tomek: Why do we check if `codeVersion > 0`? | |||||
marcinAuthorUnsubmitted Done Inline ActionsThis is an artifact from debugging. I forgot to set it to FUTURE_CODE_VERSION. marcin: This is an artifact from debugging. I forgot to set it to `FUTURE_CODE_VERSION`. | |||||
const notifID = collapseKey ? collapseKey : dbID; | const notifID = collapseKey ? collapseKey : dbID; | ||||
const { merged, ...rest } = notifTexts; | const { merged, ...rest } = notifTexts; | ||||
const notification = { | const notification = { | ||||
data: { | data: { | ||||
badge: unreadCount.toString(), | badge: unreadCount.toString(), | ||||
...rest, | ...rest, | ||||
threadID, | threadID, | ||||
}, | }, | ||||
Show All 14 Lines | ): Promise<Array<AndroidNotification>> { | ||||
} | } | ||||
const messageInfos = JSON.stringify(newRawMessageInfos); | const messageInfos = JSON.stringify(newRawMessageInfos); | ||||
const copyWithMessageInfos = { | const copyWithMessageInfos = { | ||||
...notification, | ...notification, | ||||
data: { ...notification.data, messageInfos }, | data: { ...notification.data, messageInfos }, | ||||
}; | }; | ||||
if ( | let notifications, notificationsWithMessageInfos; | ||||
Buffer.byteLength(JSON.stringify(copyWithMessageInfos)) <= | if (shouldBeEncrypted && cookieIDs) { | ||||
fcmMaxNotificationPayloadByteSize | [notifications, notificationsWithMessageInfos] = await Promise.all([ | ||||
) { | prepareEncryptedAndroidNotifications(cookieIDs, notification), | ||||
return copyWithMessageInfos; | prepareEncryptedAndroidNotifications(cookieIDs, copyWithMessageInfos), | ||||
tomekUnsubmitted Not Done Inline ActionsIs it a good idea to encrypt both of these? Is the session state affected by that? tomek: Is it a good idea to encrypt both of these? Is the session state affected by that? | |||||
marcinAuthorUnsubmitted Done Inline Actions
We must encrypt both since it is the only way to get to know if adding messageInfos does not overflow fcm push limit.
It is but not in a dangerous way. Only one of those will be sent to client, and the other can be viewed as skipped messages. The client can handle skipped messages by advancing its ratchet keys. Every ratchet-encrypted message contains a header with the counter, so that the recipent can advance their keys if they received messages no. 1, 2 and 5 but didn't receive message 3 and 4. It can therefore decrypt 5 when it arrives and 3, 4 in case they happen to arrive later. marcin: > Is it a good idea to encrypt both of these?
We must encrypt both since it is the only way to… | |||||
]); | |||||
} else { | |||||
notifications = [notification]; | |||||
notificationsWithMessageInfos = [copyWithMessageInfos]; | |||||
} | } | ||||
const shouldAddMessageInfos = notificationsWithMessageInfos.map( | |||||
notif => | |||||
Buffer.byteLength(JSON.stringify(notif)) <= | |||||
fcmMaxNotificationPayloadByteSize, | |||||
); | |||||
const notificationsToSend = shouldAddMessageInfos.map( | |||||
(addMessageInfos, idx) => { | |||||
if (addMessageInfos) { | |||||
return notificationsWithMessageInfos[idx]; | |||||
} | |||||
return notifications[idx]; | |||||
}, | |||||
); | |||||
for (const notif of notificationsToSend) { | |||||
if ( | if ( | ||||
Buffer.byteLength(JSON.stringify(notification)) > | Buffer.byteLength(JSON.stringify(notif)) > | ||||
fcmMaxNotificationPayloadByteSize | fcmMaxNotificationPayloadByteSize | ||||
) { | ) { | ||||
console.warn( | console.warn( | ||||
`Android notification ${notifID} exceeds size limit, even with messageInfos omitted`, | `Android notification ${notifID} exceeds size limit, even with messageInfos omitted`, | ||||
); | ); | ||||
} | } | ||||
return notification; | } | ||||
return notificationsToSend; | |||||
} | } | ||||
ashoatUnsubmitted Not Done Inline ActionsSame feedback as on iOS diffs – we should merge these so we only have to do a single pass ashoat: Same feedback as on iOS diffs – we should merge these so we only have to do a single pass | |||||
type WebNotifInputData = { | type WebNotifInputData = { | ||||
+notifTexts: ResolvedNotifTexts, | +notifTexts: ResolvedNotifTexts, | ||||
+threadID: string, | +threadID: string, | ||||
+unreadCount: number, | +unreadCount: number, | ||||
+platformDetails: PlatformDetails, | +platformDetails: PlatformDetails, | ||||
}; | }; | ||||
const webNotifInputDataValidator = tShape<WebNotifInputData>({ | const webNotifInputDataValidator = tShape<WebNotifInputData>({ | ||||
▲ Show 20 Lines • Show All 134 Lines • ▼ Show 20 Lines | type AndroidDelivery = { | ||||
errors?: $ReadOnlyArray<Object>, | errors?: $ReadOnlyArray<Object>, | ||||
}; | }; | ||||
type AndroidResult = { | type AndroidResult = { | ||||
info: NotificationInfo, | info: NotificationInfo, | ||||
delivery: AndroidDelivery, | delivery: AndroidDelivery, | ||||
invalidTokens?: $ReadOnlyArray<string>, | invalidTokens?: $ReadOnlyArray<string>, | ||||
}; | }; | ||||
async function sendAndroidNotification( | async function sendAndroidNotification( | ||||
notification: Object, | notifications: $ReadOnlyArray<AndroidNotification>, | ||||
deviceTokens: $ReadOnlyArray<string>, | deviceTokens: $ReadOnlyArray<string>, | ||||
notificationInfo: NotificationInfo, | notificationInfo: NotificationInfo, | ||||
): Promise<AndroidResult> { | ): Promise<AndroidResult> { | ||||
const collapseKey = notificationInfo.collapseKey | const collapseKey = notificationInfo.collapseKey | ||||
? notificationInfo.collapseKey | ? notificationInfo.collapseKey | ||||
: null; // for Flow... | : null; // for Flow... | ||||
const { source, codeVersion } = notificationInfo; | const { source, codeVersion } = notificationInfo; | ||||
const response = await fcmPush({ | const response = await fcmPush({ | ||||
notification, | notifications, | ||||
deviceTokens, | deviceTokens, | ||||
collapseKey, | collapseKey, | ||||
codeVersion, | codeVersion, | ||||
}); | }); | ||||
const androidIDs = response.fcmIDs ? response.fcmIDs : []; | const androidIDs = response.fcmIDs ? response.fcmIDs : []; | ||||
const delivery: AndroidDelivery = { | const delivery: AndroidDelivery = { | ||||
source, | source, | ||||
deviceType: 'android', | deviceType: 'android', | ||||
▲ Show 20 Lines • Show All 215 Lines • ▼ Show 20 Lines | ] of iosVersionsToTokens) { | ||||
codeVersion, | codeVersion, | ||||
}), | }), | ||||
); | ); | ||||
} | } | ||||
} | } | ||||
const androidVersionsToTokens = byPlatform.get('android'); | const androidVersionsToTokens = byPlatform.get('android'); | ||||
if (androidVersionsToTokens) { | if (androidVersionsToTokens) { | ||||
for (const [codeVersion, { deviceTokens }] of androidVersionsToTokens) { | for (const [ | ||||
codeVersion, | |||||
{ cookieIDs, deviceTokens }, | |||||
] of androidVersionsToTokens) { | |||||
const notificationData = | const notificationData = | ||||
codeVersion < 69 | codeVersion < 69 | ||||
? { badge: unreadCount.toString() } | ? { badge: unreadCount.toString() } | ||||
: { badge: unreadCount.toString(), badgeOnly: '1' }; | : { badge: unreadCount.toString(), badgeOnly: '1' }; | ||||
const notification = { data: notificationData }; | const notification = { data: notificationData }; | ||||
const notificationsArray = | |||||
codeVersion > 0 && cookieIDs | |||||
? await prepareEncryptedAndroidNotifications( | |||||
[...cookieIDs], | |||||
notification, | |||||
) | |||||
: [notification]; | |||||
deliveryPromises.push( | deliveryPromises.push( | ||||
sendAndroidNotification(notification, [...deviceTokens], { | sendAndroidNotification(notificationsArray, [...deviceTokens], { | ||||
source, | source, | ||||
dbID, | dbID, | ||||
userID, | userID, | ||||
codeVersion, | codeVersion, | ||||
}), | }), | ||||
); | ); | ||||
} | } | ||||
} | } | ||||
Show All 27 Lines |
Why do we check if codeVersion > 0?