diff --git a/keyserver/src/push/providers.js b/keyserver/src/push/providers.js --- a/keyserver/src/push/providers.js +++ b/keyserver/src/push/providers.js @@ -101,6 +101,13 @@ return cachedWebPushConfig; } +async function ensureWebPushInitialized() { + if (cachedWebPushConfig) { + return; + } + await getWebPushConfig(); +} + export { getAPNPushProfileForCodeVersion, getFCMPushProfileForCodeVersion, @@ -110,4 +117,5 @@ endAPNs, getAPNsNotificationTopic, getWebPushConfig, + ensureWebPushInitialized, }; diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js --- a/keyserver/src/push/send.js +++ b/keyserver/src/push/send.js @@ -28,6 +28,7 @@ type MessageInfo, messageTypes, } from 'lib/types/message-types.js'; +import type { WebNotification } from 'lib/types/notif-types.js'; import type { ServerThreadInfo, ThreadInfo } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types.js'; import type { UserInfo } from 'lib/types/user-types.js'; @@ -41,6 +42,8 @@ getUnreadCounts, apnMaxNotificationPayloadByteSize, fcmMaxNotificationPayloadByteSize, + webPush, + type WebPushError, } from './utils.js'; import createIDs from '../creators/id-creator.js'; import { createUpdates } from '../creators/update-creator.js'; @@ -61,7 +64,7 @@ +devices: Device[], +messageInfos: RawMessageInfo[], }; -type Delivery = IOSDelivery | AndroidDelivery | { collapsedInto: string }; +type Delivery = PushDelivery | { collapsedInto: string }; type NotificationRow = { +dbID: string, +userID: string, @@ -231,6 +234,27 @@ deliveryPromises.push(deliveryPromise); } } + const webVersionsToTokens = byPlatform.get('web'); + if (webVersionsToTokens) { + for (const [codeVersion, deviceTokens] of webVersionsToTokens) { + const deliveryPromise = (async () => { + const notification = await prepareWebNotification({ + allMessageInfos, + threadInfo, + unreadCount: unreadCounts[userID], + notifTargetUserInfo: { + id: userID, + username, + }, + }); + return await sendWebNotification(notification, [...deviceTokens], { + ...notificationInfo, + codeVersion, + }); + })(); + deliveryPromises.push(deliveryPromise); + } + } for (const newMessageInfo of remainingNewMessageInfos) { const newDBID = dbIDs.shift(); @@ -290,7 +314,7 @@ // The results in deliveryResults will be combined with the rows // in rowsToSave and then written to the notifications table async function saveNotifResults( - deliveryResults: $ReadOnlyArray, + deliveryResults: $ReadOnlyArray, inputRowsToSave: Map, rescindable: boolean, ) { @@ -666,6 +690,33 @@ return notification; } +type WebNotifInputData = { + +allMessageInfos: MessageInfo[], + +threadInfo: ThreadInfo, + +unreadCount: number, + +notifTargetUserInfo: UserInfo, +}; +async function prepareWebNotification( + inputData: WebNotifInputData, +): Promise { + const { allMessageInfos, threadInfo, unreadCount, notifTargetUserInfo } = + inputData; + const id = uuidv4(); + const { merged, ...rest } = await notifTextsForMessageInfo( + allMessageInfos, + threadInfo, + notifTargetUserInfo, + getENSNames, + ); + const notification = { + ...rest, + unreadCount, + id, + threadID: threadInfo.id, + }; + return notification; +} + type NotificationInfo = | { +source: 'new_message', @@ -723,6 +774,8 @@ return result; } +type PushResult = AndroidResult | IOSResult | WebResult; +type PushDelivery = AndroidDelivery | IOSDelivery | WebDelivery; type AndroidDelivery = { source: $PropertyType, deviceType: 'android', @@ -772,6 +825,45 @@ return result; } +type WebDelivery = { + +source: $PropertyType, + +deviceType: 'web', + +deviceTokens: $ReadOnlyArray, + +codeVersion?: number, + +errors?: $ReadOnlyArray, +}; +type WebResult = { + +info: NotificationInfo, + +delivery: WebDelivery, + +invalidTokens?: $ReadOnlyArray, +}; +async function sendWebNotification( + notification: WebNotification, + deviceTokens: $ReadOnlyArray, + notificationInfo: NotificationInfo, +): Promise { + const { source, codeVersion } = notificationInfo; + + const response = await webPush({ + notification, + deviceTokens, + }); + + const delivery: WebDelivery = { + source, + deviceType: 'web', + deviceTokens, + codeVersion, + errors: response.errors, + }; + const result: WebResult = { + info: notificationInfo, + delivery, + invalidTokens: response.invalidTokens, + }; + return result; +} + type InvalidToken = { +userID: string, +tokens: $ReadOnlyArray, diff --git a/keyserver/src/push/utils.js b/keyserver/src/push/utils.js --- a/keyserver/src/push/utils.js +++ b/keyserver/src/push/utils.js @@ -4,7 +4,9 @@ import type { ResponseFailure } from '@parse/node-apn'; import type { FirebaseApp, FirebaseError } from 'firebase-admin'; import invariant from 'invariant'; +import webpush from 'web-push'; +import type { WebNotification } from 'lib/types/notif-types.js'; import { threadSubscriptions } from 'lib/types/subscription-types.js'; import { threadPermissions } from 'lib/types/thread-types.js'; @@ -13,6 +15,7 @@ getFCMPushProfileForCodeVersion, getAPNProvider, getFCMProvider, + ensureWebPushInitialized, } from './providers.js'; import { dbQuery, SQL } from '../database/database.js'; @@ -25,6 +28,7 @@ const apnBadRequestErrorCode = 400; const apnBadTokenErrorString = 'BadDeviceToken'; const apnMaxNotificationPayloadByteSize = 4096; +const webInvalidTokenErrorCodes = [404, 410]; type APNPushResult = | { +success: true } @@ -198,9 +202,66 @@ 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 { + 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 {}; + }), + ); + + const errors = []; + const invalidTokens = []; + 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 { apnPush, fcmPush, + webPush, getUnreadCounts, apnMaxNotificationPayloadByteSize, fcmMaxNotificationPayloadByteSize, diff --git a/lib/types/notif-types.js b/lib/types/notif-types.js --- a/lib/types/notif-types.js +++ b/lib/types/notif-types.js @@ -15,3 +15,12 @@ +title: string, +prefix?: string, }; + +export type WebNotification = { + +body: string, + +prefix?: string, + +title: string, + +unreadCount: number, + +id: string, + +threadID: string, +};