diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js index c56e35d550..30fc4fc4f6 100644 --- a/spec/APNS.spec.js +++ b/spec/APNS.spec.js @@ -1,4 +1,4 @@ -var APNS = require('../src/APNS'); +var APNS = require('../src/APNS').APNS; describe('APNS', () => { diff --git a/spec/GCM.spec.js b/spec/GCM.spec.js index ceb1536820..23a2e87c98 100644 --- a/spec/GCM.spec.js +++ b/spec/GCM.spec.js @@ -1,4 +1,4 @@ -var GCM = require('../src/GCM'); +var GCM = require('../src/GCM').GCM; describe('GCM', () => { it('can initialize', (done) => { diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index e21a9dbb21..0f29305e5d 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -1,6 +1,6 @@ var ParsePushAdapter = require('../src/Adapters/Push/ParsePushAdapter'); -var APNS = require('../src/APNS'); -var GCM = require('../src/GCM'); +var APNS = require('../src/APNS').APNS; +var GCM = require('../src/GCM').GCM; describe('ParsePushAdapter', () => { it('can be initialized', (done) => { diff --git a/spec/SNSPushAdapter.spec.js b/spec/SNSPushAdapter.spec.js new file mode 100644 index 0000000000..13a3168eb4 --- /dev/null +++ b/spec/SNSPushAdapter.spec.js @@ -0,0 +1,331 @@ +var SNSPushAdapter = require('../src/Adapters/Push/SNSPushAdapter'); +describe('SNSPushAdapter', () => { + + var pushConfig; + var snsPushAdapter; + + beforeEach(function() { + pushConfig = { + pushTypes: { + ios: {ARN : "APNS_ID", production: false, bundleId: 'com.parseplatform.myapp'}, + android: {ARN: "GCM_ID"} + }, + accessKey: "accessKey", + secretKey: "secretKey", + region: "region" + }; + snsPushAdapter = new SNSPushAdapter(pushConfig); + }); + + it('can be initialized', (done) => { + // Make mock config + var snsPushConfig = snsPushAdapter.snsConfig; + + expect(snsPushConfig).toEqual(pushConfig.pushTypes); + + done(); + }); + + it('can get valid push types', (done) => { + expect(snsPushAdapter.getValidPushTypes()).toEqual(['ios', 'android']); + done(); + }); + + it('can classify installation', (done) => { + // Mock installations + var validPushTypes = ['ios', 'android']; + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + }, + { + deviceType: 'ios', + deviceToken: 'iosToken' + }, + { + deviceType: 'win', + deviceToken: 'winToken' + }, + { + deviceType: 'android', + deviceToken: undefined + } + ]; + var deviceMap = SNSPushAdapter.classifyInstallations(installations, validPushTypes); + expect(deviceMap['android']).toEqual([makeDevice('androidToken')]); + expect(deviceMap['ios']).toEqual([makeDevice('iosToken')]); + expect(deviceMap['win']).toBe(undefined); + done(); + }); + + it('can send push notifications', (done) => { + // Mock SNS sender + var snsSender = jasmine.createSpyObj('sns', ['createPlatformEndpoint', 'publish']); + snsPushAdapter.sns = snsSender; + + // Mock android ios senders + var androidSender = jasmine.createSpy('send') + var iosSender = jasmine.createSpy('send') + + var senderMap = { + ios: iosSender, + android: androidSender + }; + snsPushAdapter.senderMap = senderMap; + + // Mock installations + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + }, + { + deviceType: 'ios', + deviceToken: 'iosToken' + }, + { + deviceType: 'win', + deviceToken: 'winToken' + }, + { + deviceType: 'android', + deviceToken: undefined + } + ]; + var data = {}; + + snsPushAdapter.send(data, installations); + // Check SNS sender + expect(androidSender).toHaveBeenCalled(); + var args = androidSender.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('androidToken') + ]); + // Check ios sender + expect(iosSender).toHaveBeenCalled(); + args = iosSender.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('iosToken') + ]); + done(); + }); + + it('can generate the right Android payload', (done) => { + var data = {"action": "com.example.UPDATE_STATUS"}; + var timeStamp = 1456728000; + + var returnedData = SNSPushAdapter.generateAndroidPayload(data, timeStamp); + var expectedData = {GCM: '{"priority":"normal","data":{"time":"1970-01-17T20:38:48.000Z"}}'}; + expect(returnedData).toEqual(expectedData) + done(); + }); + + it('can generate the right iOS payload', (done) => { + var data = {data : {"alert": "Check out these awesome deals!"}}; + var timeStamp = 1456728000; + + var returnedData = SNSPushAdapter.generateiOSPayload(data, true); + var expectedData = {APNS: '{"aps":{"alert":"Check out these awesome deals!"}}'}; + + var returnedData = SNSPushAdapter.generateiOSPayload(data, false); + var expectedData = {APNS_SANDBOX: '{"aps":{"alert":"Check out these awesome deals!"}}'}; + + expect(returnedData).toEqual(expectedData); + done(); + }); + + it('can exchange device tokens for an Amazon Resource Number (ARN)', (done) => { + // Mock out Amazon SNS token exchange + var snsSender = jasmine.createSpyObj('sns', ['createPlatformEndpoint']); + snsPushAdapter.sns = snsSender; + + // Mock installations + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + } + ]; + + snsSender.createPlatformEndpoint.and.callFake(function(object, callback) { + callback(null, {'EndpointArn' : 'ARN'}); + }); + + var promise = snsPushAdapter.exchangeTokenPromise(makeDevice("androidToken"), "GCM_ID"); + + promise.then(function() { + expect(snsSender.createPlatformEndpoint).toHaveBeenCalled(); + var args = snsSender.createPlatformEndpoint.calls.first().args; + expect(args[0].PlatformApplicationArn).toEqual("GCM_ID"); + expect(args[0].Token).toEqual("androidToken"); + done(); + }); + }); + + it('can send SNS Payload', (done) => { + // Mock out Amazon SNS token exchange + var snsSender = jasmine.createSpyObj('sns', ['publish']) + snsSender.publish.and.callFake(function (object, callback) { + callback(null, '123'); + }); + + snsPushAdapter.sns = snsSender; + + // Mock installations + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + } + ]; + + var promise = snsPushAdapter.sendSNSPayload("123", {"test": "hello"}); + + var callback = jasmine.createSpy(); + promise.then(function () { + expect(snsSender.publish).toHaveBeenCalled(); + var args = snsSender.publish.calls.first().args; + expect(args[0].MessageStructure).toEqual("json"); + expect(args[0].TargetArn).toEqual("123"); + expect(args[0].Message).toEqual('{"test":"hello"}'); + done(); + }); + }); + + it('errors sending SNS Payload to Android and iOS', (done) => { + // Mock out Amazon SNS token exchange + var snsSender = jasmine.createSpyObj('sns', ['publish', 'createPlatformEndpoint']); + + snsSender.createPlatformEndpoint.and.callFake(function (object, callback) { + callback("error", {}); + }); + + snsPushAdapter.getPlatformArn(makeDevice("android"), "android", function(err, data) { + expect(err).not.toBe(null); + done(); + }); + }); + + it('can send SNS Payload to Android and iOS', (done) => { + // Mock out Amazon SNS token exchange + var snsSender = jasmine.createSpyObj('sns', ['publish', 'createPlatformEndpoint']); + + snsSender.createPlatformEndpoint.and.callFake(function (object, callback) { + callback(null, {'EndpointArn': 'ARN'}); + }); + + snsSender.publish.and.callFake(function (object, callback) { + callback(null, '123'); + }); + + snsPushAdapter.sns = snsSender; + + // Mock installations + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + }, + { + deviceType: 'ios', + deviceToken: 'iosToken' + } + ]; + + var promise = snsPushAdapter.send({"test": "hello"}, installations); + + promise.then(function () { + expect(snsSender.publish).toHaveBeenCalled(); + expect(snsSender.publish.calls.count()).toEqual(2); + done(); + }); + }); + + it('can send to APNS with known identifier', (done) => { + var snsSender = jasmine.createSpyObj('sns', ['publish', 'createPlatformEndpoint']); + + snsSender.createPlatformEndpoint.and.callFake(function (object, callback) { + callback(null, {'EndpointArn': 'ARN'}); + }); + + snsSender.publish.and.callFake(function (object, callback) { + callback(null, '123'); + }); + + snsPushAdapter.sns = snsSender; + + var promises = snsPushAdapter.sendToAPNS({"test": "hello"}, [makeDevice("ios", "com.parseplatform.myapp")]); + expect(promises.length).toEqual(1); + + Promise.all(promises).then(function () { + expect(snsSender.publish).toHaveBeenCalled(); + done(); + }); + + }); + + it('can send to APNS with unknown identifier', (done) => { + var snsSender = jasmine.createSpyObj('sns', ['publish', 'createPlatformEndpoint']); + + snsSender.createPlatformEndpoint.and.callFake(function (object, callback) { + callback(null, {'EndpointArn': 'ARN'}); + }); + + snsSender.publish.and.callFake(function (object, callback) { + callback(null, '123'); + }); + + snsPushAdapter.sns = snsSender; + + var promises = snsPushAdapter.sendToAPNS({"test": "hello"}, [makeDevice("ios", "com.parseplatform.unknown")]); + expect(promises.length).toEqual(0); + done(); + }); + + it('can send to APNS with multiple identifiers', (done) => { + pushConfig = { + pushTypes: { + ios: [{ARN : "APNS_SANDBOX_ID", production: false, bundleId: 'beta.parseplatform.myapp'}, + {ARN : "APNS_PROD_ID", production: true, bundleId: 'com.parseplatform.myapp'}], + android: {ARN: "GCM_ID"} + }, + accessKey: "accessKey", + secretKey: "secretKey", + region: "region" + }; + + snsPushAdapter = new SNSPushAdapter(pushConfig); + + var snsSender = jasmine.createSpyObj('sns', ['publish', 'createPlatformEndpoint']); + + snsSender.createPlatformEndpoint.and.callFake(function (object, callback) { + callback(null, {'EndpointArn': 'APNS_PROD_ID'}); + }); + + snsSender.publish.and.callFake(function (object, callback) { + callback(null, '123'); + }); + + snsPushAdapter.sns = snsSender; + + var promises = snsPushAdapter.sendToAPNS({"test": "hello"}, [makeDevice("ios", "beta.parseplatform.myapp")]); + expect(promises.length).toEqual(1); + Promise.all(promises).then(function () { + expect(snsSender.publish).toHaveBeenCalled(); + var args = snsSender.publish.calls.first().args[0]; + expect(args.Message).toEqual("{\"APNS_SANDBOX\":\"{}\"}"); + done(); + }); + }); + + function makeDevice(deviceToken, appIdentifier) { + return { + deviceToken: deviceToken, + appIdentifier: appIdentifier + }; + } + +}); diff --git a/src/APNS.js b/src/APNS.js index 69389ce8f7..f9e633ee73 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -16,7 +16,7 @@ const apn = require('apn'); * @param {String} args.bundleId The bundleId for cert * @param {Boolean} args.production Specifies which environment to connect to: Production (if true) or Sandbox */ -function APNS(args) { +export function APNS(args) { // Since for ios, there maybe multiple cert/key pairs, // typePushConfig can be an array. let apnsArgsList = []; @@ -187,7 +187,7 @@ function chooseConns(conns, device) { * @param {Object} coreData The data field under api request body * @returns {Object} A apns notification */ -function generateNotification(coreData, expirationTime) { +export function generateNotification(coreData, expirationTime) { let notification = new apn.notification(); let payload = {}; for (let key in coreData) { @@ -224,4 +224,3 @@ if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { APNS.chooseConns = chooseConns; APNS.handleTransmissionError = handleTransmissionError; } -module.exports = APNS; diff --git a/src/Adapters/Push/ParsePushAdapter.js b/src/Adapters/Push/ParsePushAdapter.js index 72cd57ed1b..4ae06cccc4 100644 --- a/src/Adapters/Push/ParsePushAdapter.js +++ b/src/Adapters/Push/ParsePushAdapter.js @@ -4,8 +4,8 @@ // for ios push. const Parse = require('parse/node').Parse; -const GCM = require('../../GCM'); -const APNS = require('../../APNS'); +const GCM = require('../../GCM').GCM; +const APNS = require('../../APNS').APNS; import PushAdapter from './PushAdapter'; import { classifyInstallations } from './PushAdapterUtils'; diff --git a/src/Adapters/Push/SNSPushAdapter.js b/src/Adapters/Push/SNSPushAdapter.js new file mode 100644 index 0000000000..3d1833bc22 --- /dev/null +++ b/src/Adapters/Push/SNSPushAdapter.js @@ -0,0 +1,232 @@ +"use strict"; +// SNSAdapter +// +// Uses SNS for push notification +import PushAdapter from './PushAdapter'; + +const Parse = require('parse/node').Parse; +const GCM = require('../../GCM'); +const APNS = require('../../APNS'); + +const AWS = require('aws-sdk'); + +var DEFAULT_REGION = "us-east-1"; +import { classifyInstallations } from './PushAdapterUtils'; + +export class SNSPushAdapter extends PushAdapter { + + // Publish to an SNS endpoint + // Providing AWS access and secret keys is mandatory + // Region will use sane defaults if omitted + constructor(pushConfig = {}) { + super(pushConfig); + this.validPushTypes = ['ios', 'android']; + this.availablePushTypes = []; + this.snsConfig = pushConfig.pushTypes; + this.senderMap = {}; + + if (!pushConfig.accessKey || !pushConfig.secretKey) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Need to provide AWS keys'); + } + + if (pushConfig.pushTypes) { + let pushTypes = Object.keys(pushConfig.pushTypes); + for (let pushType of pushTypes) { + if (this.validPushTypes.indexOf(pushType) < 0) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Push to ' + pushTypes + ' is not supported'); + } + this.availablePushTypes.push(pushType); + switch (pushType) { + case 'ios': + this.senderMap[pushType] = this.sendToAPNS.bind(this); + break; + case 'android': + this.senderMap[pushType] = this.sendToGCM.bind(this); + break; + } + } + } + + AWS.config.update({ + accessKeyId: pushConfig.accessKey, + secretAccessKey: pushConfig.secretKey, + region: pushConfig.region || DEFAULT_REGION + }); + + // Instantiate after config is setup. + this.sns = new AWS.SNS(); + } + + getValidPushTypes() { + return this.availablePushTypes; + } + + static classifyInstallations(installations, validTypes) { + return classifyInstallations(installations, validTypes) + } + + //Generate proper json for APNS message + static generateiOSPayload(data, production) { + var prefix = ""; + + if (production) { + prefix = "APNS"; + } else { + prefix = "APNS_SANDBOX" + } + + var notification = APNS.generateNotification(data.data, data.expirationTime); + + var payload = {}; + payload[prefix] = notification.compile(); + return payload; + } + + // Generate proper json for GCM message + static generateAndroidPayload(data, pushId, timeStamp) { + var payload = GCM.generateGCMPayload(data.data, pushId, timeStamp, data.expirationTime); + + // SNS is verify sensitive to the body being JSON stringified but not GCM key. + return { + 'GCM': JSON.stringify(payload) + }; + } + + sendToAPNS(data, devices) { + + var iosPushConfig = this.snsConfig['ios']; + + let iosConfigs = []; + if (Array.isArray(iosPushConfig)) { + iosConfigs = iosConfigs.concat(iosPushConfig); + } else { + iosConfigs.push(iosPushConfig) + } + + let promises = []; + + for (let iosConfig of iosConfigs) { + + let production = iosConfig.production || false; + var payload = SNSPushAdapter.generateiOSPayload(data, production); + + var deviceSends = []; + for (let device of devices) { + + // Follow the same logic as APNS service. If no appIdentifier, send it! + if (!device.appIdentifier || device.appIdentifier === '') { + deviceSends.push(device); + } + + else if (device.appIdentifier === iosConfig.bundleId) { + deviceSends.push(device); + } + } + if (deviceSends.length > 0) { + promises.push(this.sendToSNS(payload, deviceSends, iosConfig.ARN)); + } + } + + return promises; + } + + sendToGCM(data, devices) { + var payload = SNSPushAdapter.generateAndroidPayload(data); + var pushConfig = this.snsConfig['android']; + + return this.sendToSNS(payload, devices, pushConfig.ARN); + } + + sendToSNS(payload, devices, platformArn) { + // Exchange the device token for the Amazon resource ID + + let exchangePromises = devices.map((device) => { + return this.exchangeTokenPromise(device, platformArn); + }); + + // Publish off to SNS! + // Bulk publishing is not yet supported on Amazon SNS. + let promises = Parse.Promise.when(exchangePromises).then(arns => { + arns.map((arn) => { + return this.sendSNSPayload(arn, payload); + }); + }); + + return promises; + } + + + /** + * Request a Amazon Resource Identifier if one is not set. + */ + getPlatformArn(device, arn, callback) { + var params = { + PlatformApplicationArn: arn, + Token: device.deviceToken + }; + + this.sns.createPlatformEndpoint(params, callback); + } + + /** + * Exchange the device token for an ARN + */ + exchangeTokenPromise(device, platformARN) { + return new Parse.Promise((resolve, reject) => { + + this.getPlatformArn(device, platformARN, (err, data) => { + if (data.EndpointArn) { + resolve(data.EndpointArn); + } else { + console.error(err); + reject(err); + } + }); + }); + } + + /** + * Send the Message, MessageStructure, and Target Amazon Resource Number (ARN) to SNS + * @param arn Amazon Resource ID + * @param payload JSON-encoded message + * @returns {Parse.Promise} + */ + sendSNSPayload(arn, payload) { + + var object = { + Message: JSON.stringify(payload), + MessageStructure: 'json', + TargetArn: arn + }; + + return new Parse.Promise((resolve, reject) => { + this.sns.publish(object, (err, data) => { + if (err != null) { + console.error("Error sending push " + err); + return reject(err); + } + resolve(object); + }); + }); + } + + // For a given config object, endpoint and payload, publish via SNS + // Returns a promise containing the SNS object publish response + send(data, installations) { + let deviceMap = classifyInstallations(installations, this.availablePushTypes); + + let sendPromises = Object.keys(deviceMap).forEach((pushType) => { + var devices = deviceMap[pushType]; + + var sender = this.senderMap[pushType]; + return sender(data, devices); + }); + + return Parse.Promise.when(sendPromises); + } +} + +export default SNSPushAdapter; +module.exports = SNSPushAdapter; diff --git a/src/GCM.js b/src/GCM.js index e3df597976..c279d581c0 100644 --- a/src/GCM.js +++ b/src/GCM.js @@ -7,7 +7,7 @@ const cryptoUtils = require('./cryptoUtils'); const GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks const GCMRegistrationTokensMax = 1000; -function GCM(args) { +export function GCM(args) { if (typeof args !== 'object' || !args.apiKey) { throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'GCM Configuration is invalid'); @@ -52,7 +52,8 @@ GCM.prototype.send = function(data, devices) { expirationTime = data['expiration_time']; } // Generate gcm payload - let gcmPayload = generateGCMPayload(data.data, timestamp, expirationTime); + let gcmPayload = generateGCMPayload(data.data, null, data.expirationTime); + // Make and send gcm request let message = new gcm.Message(gcmPayload); @@ -107,17 +108,21 @@ GCM.prototype.send = function(data, devices) { * @param {Number|undefined} expirationTime A number whose format is the Unix Epoch or undefined * @returns {Object} A promise which is resolved after we get results from gcm */ -function generateGCMPayload(coreData, timeStamp, expirationTime) { +export function generateGCMPayload(coreData, timeStamp, expirationTime) { + timeStamp = timeStamp || Date.now(); + let payloadData = { 'time': new Date(timeStamp).toISOString(), 'data': JSON.stringify(coreData) } + let payload = { priority: 'normal', data: payloadData }; + if (expirationTime) { - // The timeStamp and expiration is in milliseconds but gcm requires second + // The timeStamp and expiration is in milliseconds but gcm requires second let timeToLive = Math.floor((expirationTime - timeStamp) / 1000); if (timeToLive < 0) { timeToLive = 0; @@ -127,6 +132,7 @@ function generateGCMPayload(coreData, timeStamp, expirationTime) { } payload.timeToLive = timeToLive; } + return payload; } @@ -148,4 +154,3 @@ if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { GCM.generateGCMPayload = generateGCMPayload; GCM.sliceDevices = sliceDevices; } -module.exports = GCM;