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 @@ -30,6 +30,7 @@ } from 'lib/types/message-types.js'; import type { WebNotification, + WNSNotification, ResolvedNotifTexts, } from 'lib/types/notif-types.js'; import type { ServerThreadInfo } from 'lib/types/thread-types.js'; @@ -44,8 +45,11 @@ 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'; @@ -294,6 +298,23 @@ deliveryPromises.push(deliveryPromise); } } + const windowsVersionsToTokens = byPlatform.get('windows'); + if (windowsVersionsToTokens) { + for (const [codeVersion, deviceTokens] of windowsVersionsToTokens) { + const deliveryPromise = (async () => { + const notification = await prepareWNSNotification({ + notifTexts, + threadID: threadInfo.id, + unreadCount: unreadCounts[userID], + }); + return await sendWNSNotification(notification, [...deviceTokens], { + ...notificationInfo, + codeVersion, + }); + })(); + deliveryPromises.push(deliveryPromise); + } + } for (const newMessageInfo of remainingNewMessageInfos) { const newDBID = dbIDs.shift(); @@ -741,6 +762,31 @@ return notification; } +type WNSNotifInputData = { + +notifTexts: ResolvedNotifTexts, + +threadID: string, + +unreadCount: number, +}; +async function prepareWNSNotification( + inputData: WNSNotifInputData, +): Promise { + const { notifTexts, threadID, unreadCount } = inputData; + 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', @@ -803,8 +849,8 @@ return result; } -type PushResult = AndroidResult | APNsResult | WebResult; -type PushDelivery = AndroidDelivery | APNsDelivery | WebDelivery; +type PushResult = AndroidResult | APNsResult | WebResult | WNSResult; +type PushDelivery = AndroidDelivery | APNsDelivery | WebDelivery | WNSDelivery; type AndroidDelivery = { source: $PropertyType, deviceType: 'android', @@ -893,6 +939,48 @@ return result; } +type WNSDelivery = { + +source: $PropertyType, + +deviceType: 'windows', + +wnsIDs: $ReadOnlyArray, + +deviceTokens: $ReadOnlyArray, + +codeVersion?: number, + +errors?: $ReadOnlyArray, +}; +type WNSResult = { + +info: NotificationInfo, + +delivery: WNSDelivery, + +invalidTokens?: $ReadOnlyArray, +}; +async function sendWNSNotification( + notification: WNSNotification, + deviceTokens: $ReadOnlyArray, + notificationInfo: NotificationInfo, +): Promise { + const { source, codeVersion } = notificationInfo; + + const response = await wnsPush({ + notification, + deviceTokens, + }); + + const wnsIDs = response.wnsIDs ?? []; + const delivery: WNSDelivery = { + source, + deviceType: 'windows', + wnsIDs, + deviceTokens, + codeVersion, + errors: response.errors, + }; + const result: WNSResult = { + 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,10 +4,15 @@ import type { ResponseFailure } from '@parse/node-apn'; import type { FirebaseApp, FirebaseError } from 'firebase-admin'; import invariant from 'invariant'; +import fetch from 'node-fetch'; +import type { Response } from 'node-fetch'; import webpush from 'web-push'; import type { PlatformDetails } from 'lib/types/device-types.js'; -import type { WebNotification } from 'lib/types/notif-types.js'; +import type { + WebNotification, + WNSNotification, +} from 'lib/types/notif-types.js'; import { threadSubscriptions } from 'lib/types/subscription-types.js'; import { threadPermissions } from 'lib/types/thread-types.js'; @@ -17,6 +22,7 @@ getAPNProvider, getFCMProvider, ensureWebPushInitialized, + getWNSToken, } from './providers.js'; import { dbQuery, SQL } from '../database/database.js'; @@ -30,6 +36,8 @@ const apnBadTokenErrorString = 'BadDeviceToken'; const apnMaxNotificationPayloadByteSize = 4096; const webInvalidTokenErrorCodes = [404, 410]; +const wnsInvalidTokenErrorCodes = [404, 410]; +const wnsMaxNotificationPayloadByteSize = 5000; type APNPushResult = | { +success: true } @@ -259,11 +267,109 @@ 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 fetch(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 }; + } + + return { wnsID: result.headers.get('X-WNS-MSG-ID') }; + } catch (err) { + return { error: err }; + } +} + export { apnPush, fcmPush, webPush, + wnsPush, getUnreadCounts, apnMaxNotificationPayloadByteSize, fcmMaxNotificationPayloadByteSize, + wnsMaxNotificationPayloadByteSize, }; 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 @@ -24,3 +24,11 @@ +id: string, +threadID: string, }; + +export type WNSNotification = { + +body: string, + +prefix?: string, + +title: string, + +unreadCount: number, + +threadID: string, +};