From 308fe1498a30a85299fdc318d19e287d3cc0fa34 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sat, 12 Mar 2016 13:40:59 -0500 Subject: [PATCH 1/8] Centralizes list of system classes into Schema --- src/RestQuery.js | 5 +++-- src/RestWrite.js | 23 ++++++++++++----------- src/Schema.js | 37 ++++++++++++++++++++++++------------- 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/src/RestQuery.js b/src/RestQuery.js index 0ddc7b4ba7..fa7a0298a0 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -1,6 +1,7 @@ // An object that encapsulates everything we need to run a 'find' // operation, encoded in the REST API format. +var Schema = require('./Schema'); var Parse = require('parse/node').Parse; import { default as FilesController } from './Controllers/FilesController'; @@ -171,7 +172,7 @@ RestQuery.prototype.redirectClassNameForKey = function() { // Validates this operation against the allowClientClassCreation config. RestQuery.prototype.validateClientClassCreation = function() { - let sysClass = ['_User', '_Installation', '_Role', '_Session', '_Product']; + let sysClass = Schema.systemClasses; if (this.config.allowClientClassCreation === false && !this.auth.isMaster && sysClass.indexOf(this.className) === -1) { return this.config.database.collectionExists(this.className).then((hasClass) => { @@ -423,7 +424,7 @@ RestQuery.prototype.handleInclude = function() { this.include = this.include.slice(1); return this.handleInclude(); } - + return pathResponse; }; diff --git a/src/RestWrite.js b/src/RestWrite.js index 0aaa5d5aa1..5190233184 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -3,6 +3,7 @@ // This could be either a "create" or an "update". import cache from './cache'; +var Schema = require('./Schema'); var deepcopy = require('deepcopy'); var Auth = require('./Auth'); @@ -32,7 +33,7 @@ function RestWrite(config, auth, className, query, data, originalData) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'objectId ' + 'is an invalid field name.'); } - + // When the operation is complete, this.response may have several // fields. // response: the actual data to be returned @@ -108,7 +109,7 @@ RestWrite.prototype.getUserAndRoleACL = function() { // Validates this operation against the allowClientClassCreation config. RestWrite.prototype.validateClientClassCreation = function() { - let sysClass = ['_User', '_Installation', '_Role', '_Session', '_Product']; + let sysClass = Schema.systemClasses; if (this.config.allowClientClassCreation === false && !this.auth.isMaster && sysClass.indexOf(this.className) === -1) { return this.config.database.collectionExists(this.className).then((hasClass) => { @@ -136,7 +137,7 @@ RestWrite.prototype.runBeforeTrigger = function() { if (this.response) { return; } - + // Avoid doing any setup for triggers if there is no 'beforeSave' trigger for this class. if (!triggers.triggerExists(this.className, triggers.Types.beforeSave, this.config.applicationId)) { return Promise.resolve(); @@ -254,14 +255,14 @@ RestWrite.prototype.findUsersWithAuthData = function(authData) { }, []).filter((q) => { return typeof q !== undefined; }); - + let findPromise = Promise.resolve([]); if (query.length > 0) { findPromise = this.config.database.find( this.className, {'$or': query}, {}) } - + return findPromise; } @@ -276,9 +277,9 @@ RestWrite.prototype.handleAuthData = function(authData) { throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); } - + this.storage['authProvider'] = Object.keys(authData).join(','); - + if (results.length == 0) { this.data.username = cryptoUtils.newToken(); } else if (!this.query) { @@ -404,7 +405,7 @@ RestWrite.prototype.transformUser = function() { // Handles any followup logic RestWrite.prototype.handleFollowup = function() { - + if (this.storage && this.storage['clearSessions']) { var sessionQuery = { user: { @@ -417,7 +418,7 @@ RestWrite.prototype.handleFollowup = function() { this.config.database.destroy('_Session', sessionQuery) .then(this.handleFollowup.bind(this)); } - + if (this.storage && this.storage['sendVerificationEmail']) { delete this.storage['sendVerificationEmail']; // Fire and forget! @@ -695,7 +696,7 @@ RestWrite.prototype.runDatabaseOperation = function() { throw new Parse.Error(Parse.Error.SESSION_MISSING, 'cannot modify user ' + this.query.objectId); } - + if (this.className === '_Product' && this.data.download) { this.data.downloadName = this.data.download.name; } @@ -722,7 +723,7 @@ RestWrite.prototype.runDatabaseOperation = function() { ACL[this.data.objectId] = { read: true, write: true }; ACL['*'] = { read: true, write: false }; this.data.ACL = ACL; - } + } // Run a create return this.config.database.create(this.className, this.data, this.runOptions) .then(() => { diff --git a/src/Schema.js b/src/Schema.js index ffb7b088b1..79c49496ef 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -67,7 +67,20 @@ var defaultColumns = { "icon": {type:'File'}, "order": {type:'Number'}, "title": {type:'String'}, - "subtitle": {type:'String'}, + "subtitle": {type:'String'}, + }, + _PushStatus: { + "pushTime": {type:'String'}, + "source": {type:'String'}, // rest or web + "query": {type:'String'}, // the stringified JSON query + "payload": {type:'Object'}, // the JSON payload, + "title": {type:'String'}, + "expiry": {type:'Number'}, + "status": {type:'String'}, + "numSent": {type:'Number'}, + "pushHash": {type:'String'}, + "errorMessage": {type:'Object'}, + "sentPerType": {type:'Object'} } }; @@ -76,6 +89,8 @@ var requiredColumns = { _Role: ["name", "ACL"] } +const systemClasses = ['_User', '_Installation', '_Role', '_Session', '_Product', '_PushStatus']; + // 10 alpha numberic chars + uppercase const userIdRegex = /^[a-zA-Z0-9]{10}$/; // Anything that start with role @@ -127,13 +142,8 @@ function validateCLP(perms) { var joinClassRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/; var classAndFieldRegex = /^[A-Za-z][A-Za-z0-9_]*$/; function classNameIsValid(className) { - return ( - className === '_User' || - className === '_Installation' || - className === '_Session' || + return (systemClasses.indexOf(className) > -1 || className === '_SCHEMA' || //TODO: remove this, as _SCHEMA is not a valid class name for storing Parse Objects. - className === '_Role' || - className === '_Product' || joinClassRegex.test(className) || //Class names have the same constraints as field names, but also allow the previous additional names. fieldNameIsValid(className) @@ -284,7 +294,7 @@ class Schema { return Promise.reject(error); }); } - + updateClass(className, submittedFields, classLevelPermissions, database) { if (!this.data[className]) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); @@ -299,7 +309,7 @@ class Schema { throw new Parse.Error(255, `Field ${name} does not exist, cannot delete.`); } }); - + let newSchema = buildMergedSchemaObject(existingFields, submittedFields); let mongoObject = mongoSchemaFromFieldsAndClassNameAndCLP(newSchema, className, classLevelPermissions); if (!mongoObject.result) { @@ -327,7 +337,7 @@ class Schema { }); return Promise.all(promises); }) - .then(() => { + .then(() => { return this.setPermissions(className, classLevelPermissions) }) .then(() => { return mongoSchemaToSchemaAPIResponse(mongoObject.result) }); @@ -697,7 +707,7 @@ function mongoSchemaFromFieldsAndClassNameAndCLP(fields, className, classLevelPe error: 'currently, only one GeoPoint field may exist in an object. Adding ' + geoPoints[1] + ' when ' + geoPoints[0] + ' already exists.', }; } - + validateCLP(classLevelPermissions); if (typeof classLevelPermissions !== 'undefined') { mongoObject._metadata = mongoObject._metadata || {}; @@ -886,11 +896,11 @@ function mongoSchemaToSchemaAPIResponse(schema) { className: schema._id, fields: mongoSchemaAPIResponseFields(schema), }; - + let classLevelPermissions = DefaultClassLevelPermissions; if (schema._metadata && schema._metadata.class_permissions) { classLevelPermissions = Object.assign(classLevelPermissions, schema._metadata.class_permissions); - } + } result.classLevelPermissions = classLevelPermissions; return result; } @@ -903,4 +913,5 @@ module.exports = { buildMergedSchemaObject: buildMergedSchemaObject, mongoFieldTypeToSchemaAPIType: mongoFieldTypeToSchemaAPIType, mongoSchemaToSchemaAPIResponse, + systemClasses, }; From 4d401d9daab33fb9859658f46633d2431b39d3c0 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sat, 12 Mar 2016 14:32:39 -0500 Subject: [PATCH 2/8] Stores the _PushStatus when sending push, set pending, and running states --- spec/PushController.spec.js | 79 +++++++++++++++++++++++-------- src/Adapters/Push/PushAdapter.js | 4 +- src/Controllers/PushController.js | 41 ++++++++++++++-- src/Schema.js | 2 +- src/cryptoUtils.js | 6 ++- 5 files changed, 104 insertions(+), 28 deletions(-) diff --git a/spec/PushController.spec.js b/spec/PushController.spec.js index 9255c5c985..68e52794e5 100644 --- a/spec/PushController.spec.js +++ b/spec/PushController.spec.js @@ -105,9 +105,9 @@ describe('PushController', () => { }).toThrow(); done(); }); - + it('properly increment badges', (done) => { - + var payload = {data:{ alert: "Hello World!", badge: "Increment", @@ -122,7 +122,7 @@ describe('PushController', () => { installation.set("deviceType", "ios"); installations.push(installation); } - + while(installations.length != 15) { var installation = new Parse.Object("_Installation"); installation.set("installationId", "installation_"+installations.length); @@ -130,7 +130,7 @@ describe('PushController', () => { installation.set("deviceType", "android"); installations.push(installation); } - + var pushAdapter = { send: function(body, installations) { var badge = body.data.badge; @@ -151,14 +151,14 @@ describe('PushController', () => { return ["ios", "android"]; } } - + var config = new Config(Parse.applicationId); var auth = { isMaster: true } - + var pushController = new PushController(pushAdapter, Parse.applicationId); - Parse.Object.saveAll(installations).then((installations) => { + Parse.Object.saveAll(installations).then((installations) => { return pushController.sendPush(payload, {}, config, auth); }).then((result) => { done(); @@ -167,11 +167,11 @@ describe('PushController', () => { fail("should not fail"); done(); }); - + }); - + it('properly set badges to 1', (done) => { - + var payload = {data: { alert: "Hello World!", badge: 1, @@ -186,7 +186,7 @@ describe('PushController', () => { installation.set("deviceType", "ios"); installations.push(installation); } - + var pushAdapter = { send: function(body, installations) { var badge = body.data.badge; @@ -203,14 +203,14 @@ describe('PushController', () => { return ["ios"]; } } - + var config = new Config(Parse.applicationId); var auth = { isMaster: true } - + var pushController = new PushController(pushAdapter, Parse.applicationId); - Parse.Object.saveAll(installations).then((installations) => { + Parse.Object.saveAll(installations).then((installations) => { return pushController.sendPush(payload, {}, config, auth); }).then((result) => { done(); @@ -219,15 +219,56 @@ describe('PushController', () => { fail("should not fail"); done(); }); - + }); - + + it('properly creates _PushStatus', (done) => { + + var payload = {data: { + alert: "Hello World!", + badge: 1, + }} + + var pushAdapter = { + send: function(body, installations) { + var badge = body.data.badge; + return Promise.resolve({ + body: body, + installations: installations + }) + }, + getValidPushTypes: function() { + return ["ios"]; + } + } + + var config = new Config(Parse.applicationId); + var auth = { + isMaster: true + } + + var pushController = new PushController(pushAdapter, Parse.applicationId); + pushController.sendPush(payload, {}, config, auth).then((result) => { + let query = new Parse.Query('_PushStatus'); + return query.find({useMasterKey: true}); + }).then((results) => { + expect(results.length).toBe(1); + let result = results[0]; + expect(result.get('source')).toEqual('rest'); + expect(result.get('query')).toEqual(JSON.stringify({})); + expect(result.get('payload')).toEqual(payload.data); + expect(result.get('status')).toEqual("running"); + done(); + }); + + }); + it('should support full RESTQuery for increment', (done) => { var payload = {data: { alert: "Hello World!", badge: 'Increment', }} - + var pushAdapter = { send: function(body, installations) { return Promise.resolve(); @@ -236,12 +277,12 @@ describe('PushController', () => { return ["ios"]; } } - + var config = new Config(Parse.applicationId); var auth = { isMaster: true } - + let where = { 'deviceToken': { '$inQuery': { diff --git a/src/Adapters/Push/PushAdapter.js b/src/Adapters/Push/PushAdapter.js index 846124b5af..30cbed8f6a 100644 --- a/src/Adapters/Push/PushAdapter.js +++ b/src/Adapters/Push/PushAdapter.js @@ -4,13 +4,13 @@ // // Adapter classes must implement the following functions: // * getValidPushTypes() -// * send(devices, installations) +// * send(devices, installations, pushStatus) // // Default is ParsePushAdapter, which uses GCM for // android push and APNS for ios push. export class PushAdapter { - send(devices, installations) { } + send(devices, installations, pushStatus) { } /** * Get an array of valid push types. diff --git a/src/Controllers/PushController.js b/src/Controllers/PushController.js index 158963828d..d8b3b79215 100644 --- a/src/Controllers/PushController.js +++ b/src/Controllers/PushController.js @@ -4,8 +4,10 @@ import rest from '../rest'; import AdaptableController from './AdaptableController'; import { PushAdapter } from '../Adapters/Push/PushAdapter'; import deepcopy from 'deepcopy'; +import { md5Hash } from '../cryptoUtils'; import features from '../features'; import RestQuery from '../RestQuery'; +import RestWrite from '../RestWrite'; const FEATURE_NAME = 'push'; const UNSUPPORTED_BADGE_KEY = "unsupported"; @@ -65,7 +67,7 @@ export class PushController extends AdaptableController { } let updateWhere = deepcopy(where); - badgeUpdate = () => { + badgeUpdate = () => { let badgeQuery = new RestQuery(config, auth, '_Installation', updateWhere); return badgeQuery.buildRestWhere().then(() => { let restWhere = deepcopy(badgeQuery.restWhere); @@ -81,8 +83,13 @@ export class PushController extends AdaptableController { }) } } - - return badgeUpdate().then(() => { + let pushStatus; + return Promise.resolve().then(() => { + return this.saveInitialPushStatus(body, where, config); + }).then((res) => { + pushStatus = res.response; + return badgeUpdate(); + }).then(() => { return rest.find(config, auth, '_Installation', where); }).then((response) => { if (body.data && body.data.badge && body.data.badge == "Increment") { @@ -105,14 +112,38 @@ export class PushController extends AdaptableController { } else { payload.data.badge = parseInt(badge); } - return pushAdapter.send(payload, badgeInstallationsMap[badge]); + return pushAdapter.send(payload, badgeInstallationsMap[badge], pushStatus); }); return Promise.all(promises); } - return pushAdapter.send(body, response.results); + return pushAdapter.send(body, response.results, pushStatus); + }).then(() => { + return this.updatePushStatus({status: "running"}, pushStatus, config); }); } + saveInitialPushStatus(body, where, config, options = {source: 'rest'}) { + let pushStatus = { + pushTime: (new Date()).toISOString(), + query: JSON.stringify(where), + payload: body.data, + source: options.source, + title: options.title, + expiry: body.expiration_time, + status: "pending", + numSent: 0, + pushHash: md5Hash(JSON.stringify(body.data)), + ACL: new Parse.ACL() // lockdown! + } + let restWrite = new RestWrite(config, {isMaster: true},'_PushStatus',null, pushStatus); + return restWrite.execute(); + } + + updatePushStatus(update, pushStatus, config) { + let restWrite = new RestWrite(config, {isMaster: true}, '_PushStatus', {objectId: pushStatus.objectId, "status": "pending"}, update); + return restWrite.execute(); + } + /** * Get expiration time from the request body. * @param {Object} request A request object diff --git a/src/Schema.js b/src/Schema.js index 79c49496ef..2b3e2e3922 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -71,7 +71,7 @@ var defaultColumns = { }, _PushStatus: { "pushTime": {type:'String'}, - "source": {type:'String'}, // rest or web + "source": {type:'String'}, // rest or webui "query": {type:'String'}, // the stringified JSON query "payload": {type:'Object'}, // the JSON payload, "title": {type:'String'}, diff --git a/src/cryptoUtils.js b/src/cryptoUtils.js index 47bc2e45df..4b529293b7 100644 --- a/src/cryptoUtils.js +++ b/src/cryptoUtils.js @@ -1,6 +1,6 @@ /* @flow */ -import { randomBytes } from 'crypto'; +import { randomBytes, createHash } from 'crypto'; // Returns a new random hex string of the given even size. export function randomHexString(size: number): string { @@ -44,3 +44,7 @@ export function newObjectId(): string { export function newToken(): string { return randomHexString(32); } + +export function md5Hash(string: string): string { + return createHash('md5').update(string).digest('hex'); +} From a392c088d868186724f5904650033fba44605356 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sat, 12 Mar 2016 15:20:52 -0500 Subject: [PATCH 3/8] Uses the resolved promise from the adapter --- spec/PushController.spec.js | 19 ++++++++------ src/APNS.js | 36 ++++++++++++++++++++++----- src/Adapters/Push/ParsePushAdapter.js | 6 ++--- src/Controllers/PushController.js | 10 +++++--- src/GCM.js | 9 +++++-- 5 files changed, 57 insertions(+), 23 deletions(-) diff --git a/spec/PushController.spec.js b/spec/PushController.spec.js index 68e52794e5..97dc18c58a 100644 --- a/spec/PushController.spec.js +++ b/spec/PushController.spec.js @@ -143,8 +143,8 @@ describe('PushController', () => { } }) return Promise.resolve({ - body: body, - installations: installations + error: null + payload: body, }) }, getValidPushTypes: function() { @@ -195,8 +195,8 @@ describe('PushController', () => { expect(1).toEqual(installation.badge); }) return Promise.resolve({ - body: body, - installations: installations + payload: body, + error: null }) }, getValidPushTypes: function() { @@ -233,9 +233,10 @@ describe('PushController', () => { send: function(body, installations) { var badge = body.data.badge; return Promise.resolve({ - body: body, - installations: installations - }) + error: null, + response: "OK!", + payload: body + }); }, getValidPushTypes: function() { return ["ios"]; @@ -271,7 +272,9 @@ describe('PushController', () => { var pushAdapter = { send: function(body, installations) { - return Promise.resolve(); + return Promise.resolve({ + error:null + }); }, getValidPushTypes: function() { return ["ios"]; diff --git a/src/APNS.js b/src/APNS.js index 500be9e23f..345e3dbee3 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -66,6 +66,12 @@ function APNS(args) { }); conn.on('transmitted', function(notification, device) { + if (device.callback) { + device.callback(null, { + notification: notification, + device: device + }); + } console.log('APNS Connection %d Notification transmitted to %s', conn.index, device.token.toString('hex')); }); @@ -91,11 +97,14 @@ APNS.prototype.send = function(data, devices) { let coreData = data.data; let expirationTime = data['expiration_time']; let notification = generateNotification(coreData, expirationTime); - for (let device of devices) { + + let promises = devices.map((device) => { let qualifiedConnIndexs = chooseConns(this.conns, device); // We can not find a valid conn, just ignore this device if (qualifiedConnIndexs.length == 0) { - continue; + return Promise.resolve({ + err: 'No connection available' + }); } let conn = this.conns[qualifiedConnIndexs[0]]; let apnDevice = new apn.Device(device.deviceToken); @@ -104,9 +113,19 @@ APNS.prototype.send = function(data, devices) { if (device.appIdentifier) { apnDevice.appIdentifier = device.appIdentifier; } - conn.pushNotification(notification, apnDevice); - } - return Parse.Promise.as(); + return new Promise((resolve, reject) => { + apnDevice.callback = (err, res) => { + resolve({ + error: err, + response: res, + payload: notification, + deviceType: 'ios' + }); + } + conn.pushNotification(notification, apnDevice); + }); + }); + return Parse.Promise.when(promises); } function handleTransmissionError(conns, errCode, notification, apnDevice) { @@ -133,7 +152,12 @@ function handleTransmissionError(conns, errCode, notification, apnDevice) { } // There is no more available conns, we give up in this case if (newConnIndex < 0 || newConnIndex >= conns.length) { - console.log('APNS can not find vaild connection for %j', apnDevice.token); + if (apnDevice.callback) { + apnDevice.callback({ + error: `APNS can not find vaild connection for ${apnDevice.token}`, + code: errCode + }); + } return; } diff --git a/src/Adapters/Push/ParsePushAdapter.js b/src/Adapters/Push/ParsePushAdapter.js index c953d15763..d49c1b1c8e 100644 --- a/src/Adapters/Push/ParsePushAdapter.js +++ b/src/Adapters/Push/ParsePushAdapter.js @@ -19,7 +19,7 @@ export class ParsePushAdapter extends PushAdapter { immediatePush: true }; let pushTypes = Object.keys(pushConfig); - + for (let pushType of pushTypes) { if (this.validPushTypes.indexOf(pushType) < 0) { throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, @@ -35,7 +35,7 @@ export class ParsePushAdapter extends PushAdapter { } } } - + getValidPushTypes() { return this.validPushTypes; } @@ -43,7 +43,7 @@ export class ParsePushAdapter extends PushAdapter { static classifyInstallations(installations, validTypes) { return classifyInstallations(installations, validTypes) } - + send(data, installations) { let deviceMap = classifyInstallations(installations, this.validPushTypes); let sendPromises = []; diff --git a/src/Controllers/PushController.js b/src/Controllers/PushController.js index d8b3b79215..ae2b5318fa 100644 --- a/src/Controllers/PushController.js +++ b/src/Controllers/PushController.js @@ -92,6 +92,7 @@ export class PushController extends AdaptableController { }).then(() => { return rest.find(config, auth, '_Installation', where); }).then((response) => { + this.updatePushStatus({status: "running"}, {status:"pending", objectId: pushStatus.objectId}, config); if (body.data && body.data.badge && body.data.badge == "Increment") { // Collect the badges to reduce the # of calls let badgeInstallationsMap = response.results.reduce((map, installation) => { @@ -117,8 +118,9 @@ export class PushController extends AdaptableController { return Promise.all(promises); } return pushAdapter.send(body, response.results, pushStatus); - }).then(() => { - return this.updatePushStatus({status: "running"}, pushStatus, config); + }).then((results) => { + console.log(results); + return Promise.resolve(results); }); } @@ -139,8 +141,8 @@ export class PushController extends AdaptableController { return restWrite.execute(); } - updatePushStatus(update, pushStatus, config) { - let restWrite = new RestWrite(config, {isMaster: true}, '_PushStatus', {objectId: pushStatus.objectId, "status": "pending"}, update); + updatePushStatus(update, where, config) { + let restWrite = new RestWrite(config, {isMaster: true}, '_PushStatus', where, update); return restWrite.execute(); } diff --git a/src/GCM.js b/src/GCM.js index a13a67518e..473b5ed956 100644 --- a/src/GCM.js +++ b/src/GCM.js @@ -21,7 +21,7 @@ function GCM(args) { * @param {Array} devices A array of devices * @returns {Object} A promise which is resolved after we get results from gcm */ -GCM.prototype.send = function(data, devices) { +GCM.prototype.send = function(data, devices, callback) { let pushId = cryptoUtils.newObjectId(); let timeStamp = Date.now(); let expirationTime; @@ -52,7 +52,12 @@ GCM.prototype.send = function(data, devices) { request: message, response: response }); - sendPromise.resolve(); + sendPromise.resolve({ + error: error, + response: response, + payload: message, + deviceType: 'android' + }); }); sendPromises.push(sendPromise); } From dad50d12f5226b3b076a692c9844a2664ca13296 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sun, 13 Mar 2016 18:15:15 -0400 Subject: [PATCH 4/8] Upgrade APNS to use HTTP/2 - uses universal certificate - removes tests logs - standardized returned promises from APNS and GCM to something usable in _PushStatus --- package.json | 2 +- spec/APNS.spec.js | 263 ++++--------------- spec/GCM.spec.js | 56 +++- spec/Parse.Push.spec.js | 14 +- spec/ParseACL.spec.js | 1 - spec/ParsePushAdapter.spec.js | 4 +- spec/PushController.spec.js | 2 +- src/APNS.js | 295 ++++++++++------------ src/Adapters/Push/OneSignalPushAdapter.js | 27 +- src/Adapters/Push/ParsePushAdapter.js | 11 +- src/Adapters/Push/PushAdapterUtils.js | 3 - src/Controllers/PushController.js | 2 +- src/GCM.js | 99 +++++--- 13 files changed, 316 insertions(+), 463 deletions(-) diff --git a/package.json b/package.json index 703fb401ed..ccc9bb552d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ ], "license": "BSD-3-Clause", "dependencies": { - "apn": "^1.7.5", "aws-sdk": "~2.2.33", "babel-polyfill": "^6.5.0", "babel-runtime": "^6.5.0", @@ -29,6 +28,7 @@ "deepcopy": "^0.6.1", "express": "^4.13.4", "gcloud": "^0.28.0", + "http2": "^3.3.2", "mailgun-js": "^0.7.7", "mime": "^1.3.4", "mongodb": "~2.1.0", diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js index c56e35d550..459071edfc 100644 --- a/spec/APNS.spec.js +++ b/spec/APNS.spec.js @@ -1,3 +1,4 @@ +'use strict'; var APNS = require('../src/APNS'); describe('APNS', () => { @@ -9,17 +10,13 @@ describe('APNS', () => { production: true, bundleId: 'bundleId' } - var apns = new APNS(args); + var apns = APNS(args); - expect(apns.conns.length).toBe(1); - var apnsConnection = apns.conns[0]; - expect(apnsConnection.index).toBe(0); - expect(apnsConnection.bundleId).toBe(args.bundleId); - // TODO: Remove this checking onec we inject APNS - var prodApnsOptions = apnsConnection.options; - expect(prodApnsOptions.cert).toBe(args.cert); - expect(prodApnsOptions.key).toBe(args.key); - expect(prodApnsOptions.production).toBe(args.production); + var apnsConfiguration = apns.getConfiguration(); + expect(apnsConfiguration.bundleId).toBe(args.bundleId); + expect(apnsConfiguration.cert).toBe(args.cert); + expect(apnsConfiguration.key).toBe(args.key); + expect(apnsConfiguration.production).toBe(args.production); done(); }); @@ -39,24 +36,18 @@ describe('APNS', () => { } ] - var apns = new APNS(args); - expect(apns.conns.length).toBe(2); - var devApnsConnection = apns.conns[1]; - expect(devApnsConnection.index).toBe(1); - var devApnsOptions = devApnsConnection.options; - expect(devApnsOptions.cert).toBe(args[0].cert); - expect(devApnsOptions.key).toBe(args[0].key); - expect(devApnsOptions.production).toBe(args[0].production); - expect(devApnsConnection.bundleId).toBe(args[0].bundleId); - - var prodApnsConnection = apns.conns[0]; - expect(prodApnsConnection.index).toBe(0); - // TODO: Remove this checking onec we inject APNS - var prodApnsOptions = prodApnsConnection.options; - expect(prodApnsOptions.cert).toBe(args[1].cert); - expect(prodApnsOptions.key).toBe(args[1].key); - expect(prodApnsOptions.production).toBe(args[1].production); - expect(prodApnsOptions.bundleId).toBe(args[1].bundleId); + var apns = APNS(args); + var devApnsConfiguration = apns.getConfiguration('bundleId'); + expect(devApnsConfiguration.cert).toBe(args[0].cert); + expect(devApnsConfiguration.key).toBe(args[0].key); + expect(devApnsConfiguration.production).toBe(args[0].production); + expect(devApnsConfiguration.bundleId).toBe(args[0].bundleId); + + var prodApnsConfiguration = apns.getConfiguration('bundleIdAgain'); + expect(prodApnsConfiguration.cert).toBe(args[1].cert); + expect(prodApnsConfiguration.key).toBe(args[1].key); + expect(prodApnsConfiguration.production).toBe(args[1].production); + expect(prodApnsConfiguration.bundleId).toBe(args[1].bundleId); done(); }); @@ -73,56 +64,14 @@ describe('APNS', () => { }; var expirationTime = 1454571491354 - var notification = APNS.generateNotification(data, expirationTime); - - expect(notification.alert).toEqual(data.alert); - expect(notification.badge).toEqual(data.badge); - expect(notification.sound).toEqual(data.sound); - expect(notification.contentAvailable).toEqual(1); - expect(notification.category).toEqual(data.category); - expect(notification.payload).toEqual({ - 'key': 'value', - 'keyAgain': 'valueAgain' - }); - expect(notification.expiry).toEqual(expirationTime); - done(); - }); - - it('can choose conns for device without appIdentifier', (done) => { - // Mock conns - var conns = [ - { - bundleId: 'bundleId' - }, - { - bundleId: 'bundleIdAgain' - } - ]; - // Mock device - var device = {}; - - var qualifiedConns = APNS.chooseConns(conns, device); - expect(qualifiedConns).toEqual([0, 1]); - done(); - }); - - it('can choose conns for device with valid appIdentifier', (done) => { - // Mock conns - var conns = [ - { - bundleId: 'bundleId' - }, - { - bundleId: 'bundleIdAgain' - } - ]; - // Mock device - var device = { - appIdentifier: 'bundleId' - }; - - var qualifiedConns = APNS.chooseConns(conns, device); - expect(qualifiedConns).toEqual([0]); + var notification = APNS.generateNotification(data); + expect(notification.aps.alert).toEqual(data.alert); + expect(notification.aps.badge).toEqual(data.badge); + expect(notification.aps.sound).toEqual(data.sound); + expect(notification.aps['content-available']).toEqual(1); + expect(notification.aps.category).toEqual(data.category); + expect(notification.key).toEqual('value'); + expect(notification.keyAgain).toEqual('valueAgain'); done(); }); @@ -130,7 +79,7 @@ describe('APNS', () => { // Mock conns var conns = [ { - bundleId: 'bundleId' + bundleId: 'bundleId', }, { bundleId: 'bundleIdAgain' @@ -140,143 +89,18 @@ describe('APNS', () => { var device = { appIdentifier: 'invalid' }; - - var qualifiedConns = APNS.chooseConns(conns, device); - expect(qualifiedConns).toEqual([]); - done(); - }); - - it('can handle transmission error when notification is not in cache or device is missing', (done) => { - // Mock conns - var conns = []; - var errorCode = 1; - var notification = undefined; - var device = {}; - - APNS.handleTransmissionError(conns, errorCode, notification, device); - - var notification = {}; - var device = undefined; - - APNS.handleTransmissionError(conns, errorCode, notification, device); - done(); - }); - - it('can handle transmission error when there are other qualified conns', (done) => { - // Mock conns - var conns = [ - { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId1' - }, - { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId1' - }, - { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId2' - }, - ]; - var errorCode = 1; - var notification = {}; - var apnDevice = { - connIndex: 0, - appIdentifier: 'bundleId1' - }; - - APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); - - expect(conns[0].pushNotification).not.toHaveBeenCalled(); - expect(conns[1].pushNotification).toHaveBeenCalled(); - expect(conns[2].pushNotification).not.toHaveBeenCalled(); - done(); - }); - - it('can handle transmission error when there is no other qualified conns', (done) => { - // Mock conns - var conns = [ - { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId1' - }, - { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId1' - }, - { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId1' - }, - { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId2' - }, - { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId1' - } - ]; - var errorCode = 1; - var notification = {}; - var apnDevice = { - connIndex: 2, - appIdentifier: 'bundleId1' - }; - - APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); - - expect(conns[0].pushNotification).not.toHaveBeenCalled(); - expect(conns[1].pushNotification).not.toHaveBeenCalled(); - expect(conns[2].pushNotification).not.toHaveBeenCalled(); - expect(conns[3].pushNotification).not.toHaveBeenCalled(); - expect(conns[4].pushNotification).toHaveBeenCalled(); - done(); - }); - - it('can handle transmission error when device has no appIdentifier', (done) => { - // Mock conns - var conns = [ - { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId1' - }, - { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId2' - }, - { - pushNotification: jasmine.createSpy('pushNotification'), - bundleId: 'bundleId3' - }, - ]; - var errorCode = 1; - var notification = {}; - var apnDevice = { - connIndex: 1, - }; - - APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); - - expect(conns[0].pushNotification).not.toHaveBeenCalled(); - expect(conns[1].pushNotification).not.toHaveBeenCalled(); - expect(conns[2].pushNotification).toHaveBeenCalled(); + let apns = APNS(conns); + var config = apns.getConfiguration(device.appIdentifier); + expect(config).toBeUndefined(); done(); }); it('can send APNS notification', (done) => { var args = { - cert: 'prodCert.pem', - key: 'prodKey.pem', production: true, bundleId: 'bundleId' } - var apns = new APNS(args); - var conn = { - pushNotification: jasmine.createSpy('send'), - bundleId: 'bundleId' - }; - apns.conns = [ conn ]; + var apns = APNS(args); // Mock data var expirationTime = 1454571491354 var data = { @@ -293,15 +117,18 @@ describe('APNS', () => { } ]; - var promise = apns.send(data, devices); - expect(conn.pushNotification).toHaveBeenCalled(); - var args = conn.pushNotification.calls.first().args; - var notification = args[0]; - expect(notification.alert).toEqual(data.data.alert); - expect(notification.expiry).toEqual(data['expiration_time']); - var apnDevice = args[1] - expect(apnDevice.connIndex).toEqual(0); - expect(apnDevice.appIdentifier).toEqual('bundleId'); - done(); + apns.send(data, devices).then((results) => { + let isArray = Array.isArray(results); + expect(isArray).toBe(true); + expect(results.length).toBe(1); + // No provided certificates + expect(results[0].status).toBe(403); + expect(results[0].device).toEqual(devices[0]); + expect(results[0].transmitted).toBe(false); + done(); + }, (err) => { + fail('should not fail'); + done(); + }); }); }); diff --git a/spec/GCM.spec.js b/spec/GCM.spec.js index 30f1a99788..ceb1536820 100644 --- a/spec/GCM.spec.js +++ b/spec/GCM.spec.js @@ -23,17 +23,15 @@ describe('GCM', () => { var data = { 'alert': 'alert' }; - var pushId = 1; var timeStamp = 1454538822113; var timeStampISOStr = new Date(timeStamp).toISOString(); - var payload = GCM.generateGCMPayload(data, pushId, timeStamp); + var payload = GCM.generateGCMPayload(data, timeStamp); expect(payload.priority).toEqual('normal'); expect(payload.timeToLive).toEqual(undefined); var dataFromPayload = payload.data; expect(dataFromPayload.time).toEqual(timeStampISOStr); - expect(dataFromPayload['push_id']).toEqual(pushId); var dataFromUser = JSON.parse(dataFromPayload.data); expect(dataFromUser).toEqual(data); done(); @@ -44,18 +42,16 @@ describe('GCM', () => { var data = { 'alert': 'alert' }; - var pushId = 1; var timeStamp = 1454538822113; var timeStampISOStr = new Date(timeStamp).toISOString(); var expirationTime = 1454538922113 - var payload = GCM.generateGCMPayload(data, pushId, timeStamp, expirationTime); + var payload = GCM.generateGCMPayload(data, timeStamp, expirationTime); expect(payload.priority).toEqual('normal'); expect(payload.timeToLive).toEqual(Math.floor((expirationTime - timeStamp) / 1000)); var dataFromPayload = payload.data; expect(dataFromPayload.time).toEqual(timeStampISOStr); - expect(dataFromPayload['push_id']).toEqual(pushId); var dataFromUser = JSON.parse(dataFromPayload.data); expect(dataFromUser).toEqual(data); done(); @@ -66,18 +62,16 @@ describe('GCM', () => { var data = { 'alert': 'alert' }; - var pushId = 1; var timeStamp = 1454538822113; var timeStampISOStr = new Date(timeStamp).toISOString(); var expirationTime = 1454538822112; - var payload = GCM.generateGCMPayload(data, pushId, timeStamp, expirationTime); + var payload = GCM.generateGCMPayload(data, timeStamp, expirationTime); expect(payload.priority).toEqual('normal'); expect(payload.timeToLive).toEqual(0); var dataFromPayload = payload.data; expect(dataFromPayload.time).toEqual(timeStampISOStr); - expect(dataFromPayload['push_id']).toEqual(pushId); var dataFromUser = JSON.parse(dataFromPayload.data); expect(dataFromUser).toEqual(data); done(); @@ -88,19 +82,17 @@ describe('GCM', () => { var data = { 'alert': 'alert' }; - var pushId = 1; var timeStamp = 1454538822113; var timeStampISOStr = new Date(timeStamp).toISOString(); var expirationTime = 2454538822113; - var payload = GCM.generateGCMPayload(data, pushId, timeStamp, expirationTime); + var payload = GCM.generateGCMPayload(data, timeStamp, expirationTime); expect(payload.priority).toEqual('normal'); // Four week in second expect(payload.timeToLive).toEqual(4 * 7 * 24 * 60 * 60); var dataFromPayload = payload.data; expect(dataFromPayload.time).toEqual(timeStampISOStr); - expect(dataFromPayload['push_id']).toEqual(pushId); var dataFromUser = JSON.parse(dataFromPayload.data); expect(dataFromUser).toEqual(data); done(); @@ -139,6 +131,46 @@ describe('GCM', () => { done(); }); + it('can send GCM request', (done) => { + var gcm = new GCM({ + apiKey: 'apiKey' + }); + // Mock data + var expirationTime = 2454538822113; + var data = { + 'expiration_time': expirationTime, + 'data': { + 'alert': 'alert' + } + } + // Mock devices + var devices = [ + { + deviceToken: 'token' + }, + { + deviceToken: 'token2' + }, + { + deviceToken: 'token3' + }, + { + deviceToken: 'token4' + } + ]; + + gcm.send(data, devices).then((response) => { + expect(Array.isArray(response)).toBe(true); + expect(response.length).toEqual(devices.length); + expect(response.length).toEqual(4); + response.forEach((res, index) => { + expect(res.transmitted).toEqual(false); + expect(res.device).toEqual(devices[index]); + }) + done(); + }) + }); + it('can slice devices', (done) => { // Mock devices var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; diff --git a/spec/Parse.Push.spec.js b/spec/Parse.Push.spec.js index a2b71d5f7a..c9f91253d2 100644 --- a/spec/Parse.Push.spec.js +++ b/spec/Parse.Push.spec.js @@ -3,18 +3,20 @@ describe('Parse.Push', () => { var pushAdapter = { send: function(body, installations) { var badge = body.data.badge; - installations.forEach((installation) => { + let promises = installations.map((installation) => { if (installation.deviceType == "ios") { expect(installation.badge).toEqual(badge); expect(installation.originalBadge+1).toEqual(installation.badge); } else { expect(installation.badge).toBeUndefined(); } + return Promise.resolve({ + err: null, + deviceType: installation.deviceType, + result: true + }) }); - return Promise.resolve({ - body: body, - installations: installations - }); + return Promise.all(promises) }, getValidPushTypes: function() { return ["ios", "android"]; @@ -56,4 +58,4 @@ describe('Parse.Push', () => { done(); }); }); -}); \ No newline at end of file +}); diff --git a/spec/ParseACL.spec.js b/spec/ParseACL.spec.js index 62b30b0660..3fe5656e68 100644 --- a/spec/ParseACL.spec.js +++ b/spec/ParseACL.spec.js @@ -1153,7 +1153,6 @@ describe('Parse.ACL', () => { var query = new Parse.Query("TestClassMasterACL"); return query.find(); }).then((results) => { - console.log(JSON.stringify(results[0])); ok(!results.length, 'Should not have returned object with secure ACL.'); done(); }); diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index e21a9dbb21..cb08845c53 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -29,7 +29,9 @@ describe('ParsePushAdapter', () => { var parsePushAdapter = new ParsePushAdapter(pushConfig); // Check ios var iosSender = parsePushAdapter.senderMap['ios']; - expect(iosSender instanceof APNS).toBe(true); + expect(iosSender).not.toBe(undefined); + expect(typeof iosSender.send).toEqual('function'); + expect(typeof iosSender.getConfiguration).toEqual('function'); // Check android var androidSender = parsePushAdapter.senderMap['android']; expect(androidSender instanceof GCM).toBe(true); diff --git a/spec/PushController.spec.js b/spec/PushController.spec.js index 97dc18c58a..317678c133 100644 --- a/spec/PushController.spec.js +++ b/spec/PushController.spec.js @@ -143,7 +143,7 @@ describe('PushController', () => { } }) return Promise.resolve({ - error: null + error: null, payload: body, }) }, diff --git a/src/APNS.js b/src/APNS.js index 345e3dbee3..f95a6929bc 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -1,9 +1,30 @@ "use strict"; const Parse = require('parse/node').Parse; -// TODO: apn does not support the new HTTP/2 protocal. It is fine to use it in V1, -// but probably we will replace it in the future. -const apn = require('apn'); +const http = require('http2'); +const fs = require('fs'); +const path = require('path'); +const urlParse = require('url').parse; + +const DEV_PUSH_SERVER = 'api.development.push.apple.com'; +const PROD_PUSH_SERVER = 'api.push.apple.com'; + +const createRequestOptions = (opts, device, body) => { + let domain = opts.production === true ? PROD_PUSH_SERVER : DEV_PUSH_SERVER; + var options = urlParse(`https://${domain}/3/device/${device.deviceToken}`); + options.method = 'POST'; + options.headers = { + 'apns-expiration': opts.expiration || 0, + 'apns-priority': opts.priority || 10, + 'apns-topic': opts.bundleId || opts['apns-topic'], + 'content-length': body.length + }; + options.key = opts.key; + options.cert = opts.cert; + options.pfx = opts.pfx; + options.passphrase = opts.passphrase; + return Object.assign({}, options); +} /** * Create a new connection to the APN service. @@ -16,175 +37,115 @@ 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) { - // Since for ios, there maybe multiple cert/key pairs, - // typePushConfig can be an array. - let apnsArgsList = []; - if (Array.isArray(args)) { - apnsArgsList = apnsArgsList.concat(args); - } else if (typeof args === 'object') { - apnsArgsList.push(args); - } else { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'APNS Configuration is invalid'); +function APNS(options) { + + if (!Array.isArray(options)) { + options = [options]; } - this.conns = []; - for (let apnsArgs of apnsArgsList) { - let conn = new apn.Connection(apnsArgs); - if (!apnsArgs.bundleId) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'BundleId is mssing for %j', apnsArgs); - } - conn.bundleId = apnsArgs.bundleId; - // Set the priority of the conns, prod cert has higher priority - if (apnsArgs.production) { - conn.priority = 0; - } else { - conn.priority = 1; + let agents = {}; + + let optionsByBundle = options.reduce((memo, option) => { + try { + if (option.key && option.cert) { + option.key = fs.readFileSync(option.key); + option.cert = fs.readFileSync(option.cert); + } else if (option.pfx) { + option.pfx = fs.readFileSync(option.pfx); + } else { + throw 'Either cert AND key, OR pfx is required' + } + } catch(e) { + if (!process.env.NODE_ENV == 'test' || options.enforceCertificates) { + throw e; + } } - - // Set apns client callbacks - conn.on('connected', () => { - console.log('APNS Connection %d Connected', conn.index); + option.agent = new http.Agent({ + key: option.key, + cert: option.cert, + pfx: option.pfx, + passphrase: option.passphrase }); - - conn.on('transmissionError', (errCode, notification, apnDevice) => { - handleTransmissionError(this.conns, errCode, notification, apnDevice); - }); - - conn.on('timeout', () => { - console.log('APNS Connection %d Timeout', conn.index); - }); - - conn.on('disconnected', () => { - console.log('APNS Connection %d Disconnected', conn.index); - }); - - conn.on('socketError', () => { - console.log('APNS Connection %d Socket Error', conn.index); - }); - - conn.on('transmitted', function(notification, device) { - if (device.callback) { - device.callback(null, { - notification: notification, - device: device - }); + memo[option.bundleId] = option; + return memo; + }, {}); + + let getConfiguration = (bundleIdentifier) => { + let configuration; + if (bundleIdentifier) { + configuration = optionsByBundle[bundleIdentifier]; + if (!configuration) { + return; } - console.log('APNS Connection %d Notification transmitted to %s', conn.index, device.token.toString('hex')); - }); - - this.conns.push(conn); - } - // Sort the conn based on priority ascending, high pri first - this.conns.sort((s1, s2) => { - return s1.priority - s2.priority; - }); - // Set index of conns - for (let index = 0; index < this.conns.length; index++) { - this.conns[index].index = index; - } -} - -/** - * Send apns request. - * @param {Object} data The data we need to send, the format is the same with api request body - * @param {Array} devices A array of devices - * @returns {Object} A promise which is resolved immediately - */ -APNS.prototype.send = function(data, devices) { - let coreData = data.data; - let expirationTime = data['expiration_time']; - let notification = generateNotification(coreData, expirationTime); - - let promises = devices.map((device) => { - let qualifiedConnIndexs = chooseConns(this.conns, device); - // We can not find a valid conn, just ignore this device - if (qualifiedConnIndexs.length == 0) { - return Promise.resolve({ - err: 'No connection available' - }); } - let conn = this.conns[qualifiedConnIndexs[0]]; - let apnDevice = new apn.Device(device.deviceToken); - apnDevice.connIndex = qualifiedConnIndexs[0]; - // Add additional appIdentifier info to apn device instance - if (device.appIdentifier) { - apnDevice.appIdentifier = device.appIdentifier; + if (!configuration) { + configuration = options[0]; } - return new Promise((resolve, reject) => { - apnDevice.callback = (err, res) => { - resolve({ - error: err, - response: res, - payload: notification, - deviceType: 'ios' - }); - } - conn.pushNotification(notification, apnDevice); - }); - }); - return Parse.Promise.when(promises); -} - -function handleTransmissionError(conns, errCode, notification, apnDevice) { - console.error('APNS Notification caused error: ' + errCode + ' for device ', apnDevice, notification); - // This means the error notification is not in the cache anymore or the recepient is missing, - // we just ignore this case - if (!notification || !apnDevice) { - return + return configuration; } - // If currentConn can not send the push notification, we try to use the next available conn. - // Since conns is sorted by priority, the next conn means the next low pri conn. - // If there is no conn available, we give up on sending the notification to that device. - let qualifiedConnIndexs = chooseConns(conns, apnDevice); - let currentConnIndex = apnDevice.connIndex; - - let newConnIndex = -1; - // Find the next element of currentConnIndex in qualifiedConnIndexs - for (let index = 0; index < qualifiedConnIndexs.length - 1; index++) { - if (qualifiedConnIndexs[index] === currentConnIndex) { - newConnIndex = qualifiedConnIndexs[index + 1]; - break; + /** + * Send apns request. + * @param {Object} data The data we need to send, the format is the same with api request body + * @param {Array} devices A array of device tokens + * @returns {Object} A promises that resolves with each notificaiton sending promise + */ + let send = function(data, devices) { + // Make sure devices are in an array + if (!Array.isArray(devices)) { + devices = [devices]; } - } - // There is no more available conns, we give up in this case - if (newConnIndex < 0 || newConnIndex >= conns.length) { - if (apnDevice.callback) { - apnDevice.callback({ - error: `APNS can not find vaild connection for ${apnDevice.token}`, - code: errCode + + let coreData = data.data; + let expirationTime = data['expiration_time']; + let notification = generateNotification(coreData); + let notificationString = JSON.stringify(notification); + let buffer = new Buffer(notificationString); + + let promises = devices.map((device) => { + return new Promise((resolve, reject) => { + let configuration = getConfiguration(device.appIdentifier); + if (!configuration) { + return Promise.reject({ + status: -1, + device: device, + response: {"error": "No configuration set for that appIdentifier"}, + transmitted: false + }) + } + configuration = Object.assign({}, configuration, {expiration: expirationTime }) + let requestOptions = createRequestOptions(configuration, device, buffer); + let req = configuration.agent.request(requestOptions, (response) => { + response.setEncoding('utf8'); + var chunks = ""; + response.on('data', (chunk) => { + chunks+=chunk; + }); + response.on('end', () => { + let body; + try{ + body = JSON.parse(chunks); + } catch (e) { + body = {}; + } + resolve({ status: response.statusCode, + response: body, + headers: response.headers, + device: device, + transmitted: response.statusCode == 200 }); + }); + }); + req.write(buffer); + req.end(); }); - } - return; + }); + return Promise.all(promises); } - let newConn = conns[newConnIndex]; - // Update device conn info - apnDevice.connIndex = newConnIndex; - // Use the new conn to send the notification - newConn.pushNotification(notification, apnDevice); -} - -function chooseConns(conns, device) { - // If device does not have appIdentifier, all conns maybe proper connections. - // Otherwise we try to match the appIdentifier with bundleId - let qualifiedConns = []; - for (let index = 0; index < conns.length; index++) { - let conn = conns[index]; - // If the device we need to send to does not have - // appIdentifier, any conn could be a qualified connection - if (!device.appIdentifier || device.appIdentifier === '') { - qualifiedConns.push(index); - continue; - } - if (device.appIdentifier === conn.bundleId) { - qualifiedConns.push(index); - } - } - return qualifiedConns; + return Object.freeze({ + send: send, + getConfiguration: getConfiguration + }) } /** @@ -193,12 +154,12 @@ function chooseConns(conns, device) { * @returns {Object} A apns notification */ function generateNotification(coreData, expirationTime) { - let notification = new apn.notification(); let payload = {}; + let notification = {}; for (let key in coreData) { switch (key) { case 'alert': - notification.setAlertText(coreData.alert); + notification.alert = coreData.alert; break; case 'badge': notification.badge = coreData.badge; @@ -207,9 +168,10 @@ function generateNotification(coreData, expirationTime) { notification.sound = coreData.sound; break; case 'content-available': - notification.setNewsstandAvailable(true); let isAvailable = coreData['content-available'] === 1; - notification.setContentAvailable(isAvailable); + if (isAvailable) { + notification['content-available'] = 1; + } break; case 'category': notification.category = coreData.category; @@ -219,14 +181,11 @@ function generateNotification(coreData, expirationTime) { break; } } - notification.payload = payload; - notification.expiry = expirationTime; - return notification; + payload.aps = notification; + return payload; } if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { APNS.generateNotification = generateNotification; - APNS.chooseConns = chooseConns; - APNS.handleTransmissionError = handleTransmissionError; } module.exports = APNS; diff --git a/src/Adapters/Push/OneSignalPushAdapter.js b/src/Adapters/Push/OneSignalPushAdapter.js index b92d00c53e..7c4d606280 100644 --- a/src/Adapters/Push/OneSignalPushAdapter.js +++ b/src/Adapters/Push/OneSignalPushAdapter.js @@ -10,11 +10,11 @@ var deepcopy = require('deepcopy'); import PushAdapter from './PushAdapter'; export class OneSignalPushAdapter extends PushAdapter { - + constructor(pushConfig = {}) { super(pushConfig); this.https = require('https'); - + this.validPushTypes = ['ios', 'android']; this.senderMap = {}; this.OneSignalConfig = {}; @@ -24,13 +24,12 @@ export class OneSignalPushAdapter extends PushAdapter { } this.OneSignalConfig['appId'] = pushConfig['oneSignalAppId']; this.OneSignalConfig['apiKey'] = pushConfig['oneSignalApiKey']; - + this.senderMap['ios'] = this.sendToAPNS.bind(this); this.senderMap['android'] = this.sendToGCM.bind(this); } - + send(data, installations) { - console.log("Sending notification to "+installations.length+" devices.") let deviceMap = classifyInstallations(installations, this.validPushTypes); let sendPromises = []; @@ -48,15 +47,15 @@ export class OneSignalPushAdapter extends PushAdapter { } return Parse.Promise.when(sendPromises); } - + static classifyInstallations(installations, validTypes) { return classifyInstallations(installations, validTypes) } - + getValidPushTypes() { return this.validPushTypes; } - + sendToAPNS(data,tokens) { data= deepcopy(data['data']); @@ -117,19 +116,19 @@ export class OneSignalPushAdapter extends PushAdapter { return promise; } - + sendToGCM(data,tokens) { data= deepcopy(data['data']); var post = {}; - + if(data['alert']) { post['contents'] = {en: data['alert']}; delete data['alert']; } if(data['title']) { post['title'] = {en: data['title']}; - delete data['title']; + delete data['title']; } if(data['uri']) { post['url'] = data['uri']; @@ -155,7 +154,7 @@ export class OneSignalPushAdapter extends PushAdapter { } }.bind(this); - this.sendNext = function() { + this.sendNext = function() { post['include_android_reg_ids'] = []; tokens.slice(offset,offset+chunk).forEach(function(i) { post['include_android_reg_ids'].push(i['deviceToken']) @@ -168,7 +167,7 @@ export class OneSignalPushAdapter extends PushAdapter { this.sendNext(); return promise; } - + sendToOneSignal(data, cb) { let headers = { "Content-Type": "application/json", @@ -188,7 +187,7 @@ export class OneSignalPushAdapter extends PushAdapter { cb(true); } else { console.log('OneSignal Error'); - res.on('data', function(chunk) { + res.on('data', function(chunk) { console.log(chunk.toString()) }); cb(false) diff --git a/src/Adapters/Push/ParsePushAdapter.js b/src/Adapters/Push/ParsePushAdapter.js index d49c1b1c8e..7b3cfa0347 100644 --- a/src/Adapters/Push/ParsePushAdapter.js +++ b/src/Adapters/Push/ParsePushAdapter.js @@ -50,11 +50,14 @@ export class ParsePushAdapter extends PushAdapter { for (let pushType in deviceMap) { let sender = this.senderMap[pushType]; if (!sender) { - console.log('Can not find sender for push type %s, %j', pushType, data); - continue; + sendPromises.push(Promise.resolve({ + transmitted: false, + response: {'error': `Can not find sender for push type ${pushType}, ${data}`} + })) + } else { + let devices = deviceMap[pushType]; + sendPromises.push(sender.send(data, devices)); } - let devices = deviceMap[pushType]; - sendPromises.push(sender.send(data, devices)); } return Parse.Promise.when(sendPromises); } diff --git a/src/Adapters/Push/PushAdapterUtils.js b/src/Adapters/Push/PushAdapterUtils.js index a78aab42fd..6a9216ec31 100644 --- a/src/Adapters/Push/PushAdapterUtils.js +++ b/src/Adapters/Push/PushAdapterUtils.js @@ -21,10 +21,7 @@ export function classifyInstallations(installations, validPushTypes) { deviceToken: installation.deviceToken, appIdentifier: installation.appIdentifier }); - } else { - console.log('Unknown push type from installation %j', installation); } } return deviceMap; } - diff --git a/src/Controllers/PushController.js b/src/Controllers/PushController.js index ae2b5318fa..1c5edc8912 100644 --- a/src/Controllers/PushController.js +++ b/src/Controllers/PushController.js @@ -119,7 +119,7 @@ export class PushController extends AdaptableController { } return pushAdapter.send(body, response.results, pushStatus); }).then((results) => { - console.log(results); + // TODO: handle push results return Promise.resolve(results); }); } diff --git a/src/GCM.js b/src/GCM.js index 473b5ed956..e3df597976 100644 --- a/src/GCM.js +++ b/src/GCM.js @@ -21,9 +21,30 @@ function GCM(args) { * @param {Array} devices A array of devices * @returns {Object} A promise which is resolved after we get results from gcm */ -GCM.prototype.send = function(data, devices, callback) { - let pushId = cryptoUtils.newObjectId(); - let timeStamp = Date.now(); +GCM.prototype.send = function(data, devices) { + // Make a new array + devices = new Array(...devices); + let timestamp = Date.now(); + // For android, we can only have 1000 recepients per send, so we need to slice devices to + // chunk if necessary + let slices = sliceDevices(devices, GCMRegistrationTokensMax); + if (slices.length > 1) { + // Make 1 send per slice + let promises = slices.reduce((memo, slice) => { + let promise = this.send(data, slice, timestamp); + memo.push(promise); + return memo; + }, []) + return Parse.Promise.when(promises).then((results) => { + let allResults = results.reduce((memo, result) => { + return memo.concat(result); + }, []); + return Parse.Promise.as(allResults); + }); + } + // get the devices back... + devices = slices[0]; + let expirationTime; // We handle the expiration_time convertion in push.js, so expiration_time is a valid date // in Unix epoch time in milliseconds here @@ -31,38 +52,51 @@ GCM.prototype.send = function(data, devices, callback) { expirationTime = data['expiration_time']; } // Generate gcm payload - let gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime); + let gcmPayload = generateGCMPayload(data.data, timestamp, expirationTime); // Make and send gcm request let message = new gcm.Message(gcmPayload); - let sendPromises = []; - // For android, we can only have 1000 recepients per send, so we need to slice devices to - // chunk if necessary - let chunkDevices = sliceDevices(devices, GCMRegistrationTokensMax); - for (let chunkDevice of chunkDevices) { - let sendPromise = new Parse.Promise(); - let registrationTokens = [] - for (let device of chunkDevice) { - registrationTokens.push(device.deviceToken); - } - this.sender.send(message, { registrationTokens: registrationTokens }, 5, (error, response) => { - // TODO: Use the response from gcm to generate and save push report - // TODO: If gcm returns some deviceTokens are invalid, set tombstone for the installation - console.log('GCM request and response %j', { - request: message, - response: response - }); - sendPromise.resolve({ - error: error, - response: response, - payload: message, - deviceType: 'android' - }); - }); - sendPromises.push(sendPromise); - } + // Build a device map + let devicesMap = devices.reduce((memo, device) => { + memo[device.deviceToken] = device; + return memo; + }, {}); + + let deviceTokens = Object.keys(devicesMap); - return Parse.Promise.when(sendPromises); + let promises = deviceTokens.map(() => new Parse.Promise()); + let registrationTokens = deviceTokens; + this.sender.send(message, { registrationTokens: registrationTokens }, 5, (error, response) => { + // example response: + /* + { "multicast_id":7680139367771848000, + "success":0, + "failure":4, + "canonical_ids":0, + "results":[ {"error":"InvalidRegistration"}, + {"error":"InvalidRegistration"}, + {"error":"InvalidRegistration"}, + {"error":"InvalidRegistration"}] } + */ + let { results, multicast_id } = response || {}; + registrationTokens.forEach((token, index) => { + let promise = promises[index]; + let result = results ? results[index] : undefined; + let device = devicesMap[token]; + let resolution = { + device, + multicast_id, + response: error || result, + }; + if (!result || result.error) { + resolution.transmitted = false; + } else { + resolution.transmitted = true; + } + promise.resolve(resolution); + }); + }); + return Parse.Promise.when(promises); } /** @@ -73,10 +107,9 @@ GCM.prototype.send = function(data, devices, callback) { * @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, pushId, timeStamp, expirationTime) { +function generateGCMPayload(coreData, timeStamp, expirationTime) { let payloadData = { 'time': new Date(timeStamp).toISOString(), - 'push_id': pushId, 'data': JSON.stringify(coreData) } let payload = { From 05baf36a4652b7e03142893ed715f13690bc85fd Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sun, 13 Mar 2016 19:47:56 -0400 Subject: [PATCH 5/8] Fix tests, uses fork of http2 to support node 4.3.0 without ALPN - the push server don't send TLS NPN and makes the http2 lib break - the fork forces the next protocol negociation to h2 as we're sure --- package.json | 2 +- spec/Parse.Push.spec.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index ccc9bb552d..019ab0314d 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "deepcopy": "^0.6.1", "express": "^4.13.4", "gcloud": "^0.28.0", - "http2": "^3.3.2", + "http2": "flovilmart/node-http2", "mailgun-js": "^0.7.7", "mime": "^1.3.4", "mongodb": "~2.1.0", diff --git a/spec/Parse.Push.spec.js b/spec/Parse.Push.spec.js index c9f91253d2..7dc02d43c8 100644 --- a/spec/Parse.Push.spec.js +++ b/spec/Parse.Push.spec.js @@ -1,3 +1,4 @@ +'use strict'; describe('Parse.Push', () => { it('should properly send push', (done) => { var pushAdapter = { From 7c387e1ee9fef29cd3d57ced706708088ec26247 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sun, 13 Mar 2016 23:34:44 -0400 Subject: [PATCH 6/8] Adds support to store push results --- spec/PushController.spec.js | 77 +++++++++++++++------ src/Adapters/Push/ParsePushAdapter.js | 5 +- src/Controllers/PushController.js | 96 ++++++++++++--------------- src/Schema.js | 4 +- src/pushStatusHandler.js | 76 +++++++++++++++++++++ 5 files changed, 183 insertions(+), 75 deletions(-) create mode 100644 src/pushStatusHandler.js diff --git a/spec/PushController.spec.js b/spec/PushController.spec.js index 317678c133..f776fb9dbd 100644 --- a/spec/PushController.spec.js +++ b/spec/PushController.spec.js @@ -3,6 +3,30 @@ var PushController = require('../src/Controllers/PushController').PushController var Config = require('../src/Config'); +const successfulTransmissions = function(body, installations) { + + let promises = installations.map((device) => { + return Promise.resolve({ + transmitted: true, + device: device, + }) + }); + + return Promise.all(promises); +} + +const successfulIOS = function(body, installations) { + + let promises = installations.map((device) => { + return Promise.resolve({ + transmitted: device.deviceType == "ios", + device: device, + }) + }); + + return Promise.all(promises); +} + describe('PushController', () => { it('can validate device type when no device type is set', (done) => { // Make query condition @@ -142,10 +166,7 @@ describe('PushController', () => { expect(installation.badge).toBeUndefined(); } }) - return Promise.resolve({ - error: null, - payload: body, - }) + return successfulTransmissions(body, installations); }, getValidPushTypes: function() { return ["ios", "android"]; @@ -194,10 +215,7 @@ describe('PushController', () => { expect(installation.badge).toEqual(badge); expect(1).toEqual(installation.badge); }) - return Promise.resolve({ - payload: body, - error: null - }) + return successfulTransmissions(body, installations); }, getValidPushTypes: function() { return ["ios"]; @@ -224,6 +242,24 @@ describe('PushController', () => { it('properly creates _PushStatus', (done) => { + var installations = []; + while(installations.length != 10) { + var installation = new Parse.Object("_Installation"); + installation.set("installationId", "installation_"+installations.length); + installation.set("deviceToken","device_token_"+installations.length) + installation.set("badge", installations.length); + installation.set("originalBadge", installations.length); + installation.set("deviceType", "ios"); + installations.push(installation); + } + + while(installations.length != 15) { + var installation = new Parse.Object("_Installation"); + installation.set("installationId", "installation_"+installations.length); + installation.set("deviceToken","device_token_"+installations.length) + installation.set("deviceType", "android"); + installations.push(installation); + } var payload = {data: { alert: "Hello World!", badge: 1, @@ -231,12 +267,7 @@ describe('PushController', () => { var pushAdapter = { send: function(body, installations) { - var badge = body.data.badge; - return Promise.resolve({ - error: null, - response: "OK!", - payload: body - }); + return successfulIOS(body, installations); }, getValidPushTypes: function() { return ["ios"]; @@ -249,7 +280,9 @@ describe('PushController', () => { } var pushController = new PushController(pushAdapter, Parse.applicationId); - pushController.sendPush(payload, {}, config, auth).then((result) => { + Parse.Object.saveAll(installations).then(() => { + return pushController.sendPush(payload, {}, config, auth); + }).then((result) => { let query = new Parse.Query('_PushStatus'); return query.find({useMasterKey: true}); }).then((results) => { @@ -258,7 +291,15 @@ describe('PushController', () => { expect(result.get('source')).toEqual('rest'); expect(result.get('query')).toEqual(JSON.stringify({})); expect(result.get('payload')).toEqual(payload.data); - expect(result.get('status')).toEqual("running"); + expect(result.get('status')).toEqual('succeeded'); + expect(result.get('numSent')).toEqual(10); + expect(result.get('sentPerType')).toEqual({ + 'ios': 10 // 10 ios + }); + expect(result.get('numFailed')).toEqual(5); + expect(result.get('failedPerType')).toEqual({ + 'android': 5 // android + }); done(); }); @@ -272,9 +313,7 @@ describe('PushController', () => { var pushAdapter = { send: function(body, installations) { - return Promise.resolve({ - error:null - }); + return successfulTransmissions(body, installations); }, getValidPushTypes: function() { return ["ios"]; diff --git a/src/Adapters/Push/ParsePushAdapter.js b/src/Adapters/Push/ParsePushAdapter.js index 7b3cfa0347..72cd57ed1b 100644 --- a/src/Adapters/Push/ParsePushAdapter.js +++ b/src/Adapters/Push/ParsePushAdapter.js @@ -10,6 +10,9 @@ import PushAdapter from './PushAdapter'; import { classifyInstallations } from './PushAdapterUtils'; export class ParsePushAdapter extends PushAdapter { + + supportsPushTracking = true; + constructor(pushConfig = {}) { super(pushConfig); this.validPushTypes = ['ios', 'android']; @@ -56,7 +59,7 @@ export class ParsePushAdapter extends PushAdapter { })) } else { let devices = deviceMap[pushType]; - sendPromises.push(sender.send(data, devices)); + sendPromises.push(sender.send(data, devices)); } } return Parse.Promise.when(sendPromises); diff --git a/src/Controllers/PushController.js b/src/Controllers/PushController.js index 1c5edc8912..efe9a750e8 100644 --- a/src/Controllers/PushController.js +++ b/src/Controllers/PushController.js @@ -4,10 +4,9 @@ import rest from '../rest'; import AdaptableController from './AdaptableController'; import { PushAdapter } from '../Adapters/Push/PushAdapter'; import deepcopy from 'deepcopy'; -import { md5Hash } from '../cryptoUtils'; import features from '../features'; import RestQuery from '../RestQuery'; -import RestWrite from '../RestWrite'; +import pushStatusHandler from '../pushStatusHandler'; const FEATURE_NAME = 'push'; const UNSUPPORTED_BADGE_KEY = "unsupported"; @@ -40,7 +39,7 @@ export class PushController extends AdaptableController { } } - sendPush(body = {}, where = {}, config, auth) { + sendPush(body = {}, where = {}, config, auth, wait) { var pushAdapter = this.adapter; if (!pushAdapter) { throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, @@ -83,67 +82,56 @@ export class PushController extends AdaptableController { }) } } - let pushStatus; + let pushStatus = pushStatusHandler(config); return Promise.resolve().then(() => { - return this.saveInitialPushStatus(body, where, config); - }).then((res) => { - pushStatus = res.response; + return pushStatus.setInitial(body, where); + }).then(() => { return badgeUpdate(); }).then(() => { return rest.find(config, auth, '_Installation', where); }).then((response) => { - this.updatePushStatus({status: "running"}, {status:"pending", objectId: pushStatus.objectId}, config); - if (body.data && body.data.badge && body.data.badge == "Increment") { - // Collect the badges to reduce the # of calls - let badgeInstallationsMap = response.results.reduce((map, installation) => { - let badge = installation.badge; - if (installation.deviceType != "ios") { - badge = UNSUPPORTED_BADGE_KEY; - } - map[badge+''] = map[badge+''] || []; - map[badge+''].push(installation); - return map; - }, {}); - - // Map the on the badges count and return the send result - let promises = Object.keys(badgeInstallationsMap).map((badge) => { - let payload = deepcopy(body); - if (badge == UNSUPPORTED_BADGE_KEY) { - delete payload.data.badge; - } else { - payload.data.badge = parseInt(badge); - } - return pushAdapter.send(payload, badgeInstallationsMap[badge], pushStatus); - }); - return Promise.all(promises); - } - return pushAdapter.send(body, response.results, pushStatus); + pushStatus.setRunning(); + return this.sendToAdapter(body, response.results, pushStatus, config); }).then((results) => { - // TODO: handle push results - return Promise.resolve(results); + return pushStatus.complete(results); }); } - saveInitialPushStatus(body, where, config, options = {source: 'rest'}) { - let pushStatus = { - pushTime: (new Date()).toISOString(), - query: JSON.stringify(where), - payload: body.data, - source: options.source, - title: options.title, - expiry: body.expiration_time, - status: "pending", - numSent: 0, - pushHash: md5Hash(JSON.stringify(body.data)), - ACL: new Parse.ACL() // lockdown! - } - let restWrite = new RestWrite(config, {isMaster: true},'_PushStatus',null, pushStatus); - return restWrite.execute(); - } + sendToAdapter(body, installations, pushStatus, config) { + if (body.data && body.data.badge && body.data.badge == "Increment") { + // Collect the badges to reduce the # of calls + let badgeInstallationsMap = installations.reduce((map, installation) => { + let badge = installation.badge; + if (installation.deviceType != "ios") { + badge = UNSUPPORTED_BADGE_KEY; + } + map[badge+''] = map[badge+''] || []; + map[badge+''].push(installation); + return map; + }, {}); - updatePushStatus(update, where, config) { - let restWrite = new RestWrite(config, {isMaster: true}, '_PushStatus', where, update); - return restWrite.execute(); + // Map the on the badges count and return the send result + let promises = Object.keys(badgeInstallationsMap).map((badge) => { + let payload = deepcopy(body); + if (badge == UNSUPPORTED_BADGE_KEY) { + delete payload.data.badge; + } else { + payload.data.badge = parseInt(badge); + } + return this.adapter.send(payload, badgeInstallationsMap[badge]); + }); + // Flatten the promises results + return Promise.all(promises).then((results) => { + if (Array.isArray(results)) { + return Promise.resolve(results.reduce((memo, result) => { + return memo.concat(result); + },[])); + } else { + return Promise.resolve(results); + } + }) + } + return this.adapter.send(body, installations); } /** diff --git a/src/Schema.js b/src/Schema.js index 2b3e2e3922..f05aced043 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -78,9 +78,11 @@ var defaultColumns = { "expiry": {type:'Number'}, "status": {type:'String'}, "numSent": {type:'Number'}, + "numFailed": {type:'Number'}, "pushHash": {type:'String'}, "errorMessage": {type:'Object'}, - "sentPerType": {type:'Object'} + "sentPerType": {type:'Object'}, + "failedPerType":{type:'Object'}, } }; diff --git a/src/pushStatusHandler.js b/src/pushStatusHandler.js new file mode 100644 index 0000000000..362ddb761c --- /dev/null +++ b/src/pushStatusHandler.js @@ -0,0 +1,76 @@ +import RestWrite from './RestWrite'; +import { md5Hash } from './cryptoUtils'; + +export default function pushStatusHandler(config) { + + let initialPromise; + let pushStatus; + let setInitial = function(body, where, options = {source: 'rest'}) { + let object = { + pushTime: (new Date()).toISOString(), + query: JSON.stringify(where), + payload: body.data, + source: options.source, + title: options.title, + expiry: body.expiration_time, + status: "pending", + numSent: 0, + pushHash: md5Hash(JSON.stringify(body.data)), + ACL: new Parse.ACL() // lockdown! + } + let restWrite = new RestWrite(config, {isMaster: true},'_PushStatus',null, object); + initialPromise = restWrite.execute().then((res) => { + pushStatus = res.response; + return Promise.resolve(pushStatus); + }); + return initialPromise; + } + + let setRunning = function() { + return initialPromise.then(() => { + let restWrite = new RestWrite(config, {isMaster: true}, '_PushStatus', {status:"pending", objectId: pushStatus.objectId}, {status: "running"}); + return restWrite.execute(); + }) + } + + let complete = function(results) { + let update = { + status: 'succeeded', + numSent: 0, + numFailed: 0, + }; + if (Array.isArray(results)) { + results.reduce((memo, result) => { + // Cannot handle that + if (!result.device || !result.device.deviceType) { + return memo; + } + let deviceType = result.device.deviceType; + if (result.transmitted) + { + memo.numSent++; + memo.sentPerType = memo.sentPerType || {}; + memo.sentPerType[deviceType] = memo.sentPerType[deviceType] || 0; + memo.sentPerType[deviceType]++; + } else { + memo.numFailed++; + memo.failedPerType = memo.failedPerType || {}; + memo.failedPerType[deviceType] = memo.failedPerType[deviceType] || 0; + memo.failedPerType[deviceType]++; + } + return memo; + }, update); + } + + return initialPromise.then(() => { + let restWrite = new RestWrite(config, {isMaster: true}, '_PushStatus', {status:"running", objectId: pushStatus.objectId}, update); + return restWrite.execute(); + }) + } + + return Object.freeze({ + setInitial, + setRunning, + complete + }) +} From 120f23c7910859694e06ea2d7082b9c4b6ba0193 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Mon, 14 Mar 2016 08:15:38 -0400 Subject: [PATCH 7/8] reverts to use binary APNs --- package.json | 2 +- spec/APNS.spec.js | 263 ++++++++++++++++++++++++------ spec/ParsePushAdapter.spec.js | 4 +- src/APNS.js | 290 +++++++++++++++++++--------------- 4 files changed, 383 insertions(+), 176 deletions(-) diff --git a/package.json b/package.json index 019ab0314d..703fb401ed 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "apn": "^1.7.5", "aws-sdk": "~2.2.33", "babel-polyfill": "^6.5.0", "babel-runtime": "^6.5.0", @@ -28,7 +29,6 @@ "deepcopy": "^0.6.1", "express": "^4.13.4", "gcloud": "^0.28.0", - "http2": "flovilmart/node-http2", "mailgun-js": "^0.7.7", "mime": "^1.3.4", "mongodb": "~2.1.0", diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js index 459071edfc..c56e35d550 100644 --- a/spec/APNS.spec.js +++ b/spec/APNS.spec.js @@ -1,4 +1,3 @@ -'use strict'; var APNS = require('../src/APNS'); describe('APNS', () => { @@ -10,13 +9,17 @@ describe('APNS', () => { production: true, bundleId: 'bundleId' } - var apns = APNS(args); + var apns = new APNS(args); - var apnsConfiguration = apns.getConfiguration(); - expect(apnsConfiguration.bundleId).toBe(args.bundleId); - expect(apnsConfiguration.cert).toBe(args.cert); - expect(apnsConfiguration.key).toBe(args.key); - expect(apnsConfiguration.production).toBe(args.production); + expect(apns.conns.length).toBe(1); + var apnsConnection = apns.conns[0]; + expect(apnsConnection.index).toBe(0); + expect(apnsConnection.bundleId).toBe(args.bundleId); + // TODO: Remove this checking onec we inject APNS + var prodApnsOptions = apnsConnection.options; + expect(prodApnsOptions.cert).toBe(args.cert); + expect(prodApnsOptions.key).toBe(args.key); + expect(prodApnsOptions.production).toBe(args.production); done(); }); @@ -36,18 +39,24 @@ describe('APNS', () => { } ] - var apns = APNS(args); - var devApnsConfiguration = apns.getConfiguration('bundleId'); - expect(devApnsConfiguration.cert).toBe(args[0].cert); - expect(devApnsConfiguration.key).toBe(args[0].key); - expect(devApnsConfiguration.production).toBe(args[0].production); - expect(devApnsConfiguration.bundleId).toBe(args[0].bundleId); - - var prodApnsConfiguration = apns.getConfiguration('bundleIdAgain'); - expect(prodApnsConfiguration.cert).toBe(args[1].cert); - expect(prodApnsConfiguration.key).toBe(args[1].key); - expect(prodApnsConfiguration.production).toBe(args[1].production); - expect(prodApnsConfiguration.bundleId).toBe(args[1].bundleId); + var apns = new APNS(args); + expect(apns.conns.length).toBe(2); + var devApnsConnection = apns.conns[1]; + expect(devApnsConnection.index).toBe(1); + var devApnsOptions = devApnsConnection.options; + expect(devApnsOptions.cert).toBe(args[0].cert); + expect(devApnsOptions.key).toBe(args[0].key); + expect(devApnsOptions.production).toBe(args[0].production); + expect(devApnsConnection.bundleId).toBe(args[0].bundleId); + + var prodApnsConnection = apns.conns[0]; + expect(prodApnsConnection.index).toBe(0); + // TODO: Remove this checking onec we inject APNS + var prodApnsOptions = prodApnsConnection.options; + expect(prodApnsOptions.cert).toBe(args[1].cert); + expect(prodApnsOptions.key).toBe(args[1].key); + expect(prodApnsOptions.production).toBe(args[1].production); + expect(prodApnsOptions.bundleId).toBe(args[1].bundleId); done(); }); @@ -64,14 +73,56 @@ describe('APNS', () => { }; var expirationTime = 1454571491354 - var notification = APNS.generateNotification(data); - expect(notification.aps.alert).toEqual(data.alert); - expect(notification.aps.badge).toEqual(data.badge); - expect(notification.aps.sound).toEqual(data.sound); - expect(notification.aps['content-available']).toEqual(1); - expect(notification.aps.category).toEqual(data.category); - expect(notification.key).toEqual('value'); - expect(notification.keyAgain).toEqual('valueAgain'); + var notification = APNS.generateNotification(data, expirationTime); + + expect(notification.alert).toEqual(data.alert); + expect(notification.badge).toEqual(data.badge); + expect(notification.sound).toEqual(data.sound); + expect(notification.contentAvailable).toEqual(1); + expect(notification.category).toEqual(data.category); + expect(notification.payload).toEqual({ + 'key': 'value', + 'keyAgain': 'valueAgain' + }); + expect(notification.expiry).toEqual(expirationTime); + done(); + }); + + it('can choose conns for device without appIdentifier', (done) => { + // Mock conns + var conns = [ + { + bundleId: 'bundleId' + }, + { + bundleId: 'bundleIdAgain' + } + ]; + // Mock device + var device = {}; + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([0, 1]); + done(); + }); + + it('can choose conns for device with valid appIdentifier', (done) => { + // Mock conns + var conns = [ + { + bundleId: 'bundleId' + }, + { + bundleId: 'bundleIdAgain' + } + ]; + // Mock device + var device = { + appIdentifier: 'bundleId' + }; + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([0]); done(); }); @@ -79,7 +130,7 @@ describe('APNS', () => { // Mock conns var conns = [ { - bundleId: 'bundleId', + bundleId: 'bundleId' }, { bundleId: 'bundleIdAgain' @@ -89,18 +140,143 @@ describe('APNS', () => { var device = { appIdentifier: 'invalid' }; - let apns = APNS(conns); - var config = apns.getConfiguration(device.appIdentifier); - expect(config).toBeUndefined(); + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([]); + done(); + }); + + it('can handle transmission error when notification is not in cache or device is missing', (done) => { + // Mock conns + var conns = []; + var errorCode = 1; + var notification = undefined; + var device = {}; + + APNS.handleTransmissionError(conns, errorCode, notification, device); + + var notification = {}; + var device = undefined; + + APNS.handleTransmissionError(conns, errorCode, notification, device); + done(); + }); + + it('can handle transmission error when there are other qualified conns', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 0, + appIdentifier: 'bundleId1' + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).toHaveBeenCalled(); + expect(conns[2].pushNotification).not.toHaveBeenCalled(); + done(); + }); + + it('can handle transmission error when there is no other qualified conns', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + } + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 2, + appIdentifier: 'bundleId1' + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).not.toHaveBeenCalled(); + expect(conns[2].pushNotification).not.toHaveBeenCalled(); + expect(conns[3].pushNotification).not.toHaveBeenCalled(); + expect(conns[4].pushNotification).toHaveBeenCalled(); + done(); + }); + + it('can handle transmission error when device has no appIdentifier', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId3' + }, + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 1, + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).not.toHaveBeenCalled(); + expect(conns[2].pushNotification).toHaveBeenCalled(); done(); }); it('can send APNS notification', (done) => { var args = { + cert: 'prodCert.pem', + key: 'prodKey.pem', production: true, bundleId: 'bundleId' } - var apns = APNS(args); + var apns = new APNS(args); + var conn = { + pushNotification: jasmine.createSpy('send'), + bundleId: 'bundleId' + }; + apns.conns = [ conn ]; // Mock data var expirationTime = 1454571491354 var data = { @@ -117,18 +293,15 @@ describe('APNS', () => { } ]; - apns.send(data, devices).then((results) => { - let isArray = Array.isArray(results); - expect(isArray).toBe(true); - expect(results.length).toBe(1); - // No provided certificates - expect(results[0].status).toBe(403); - expect(results[0].device).toEqual(devices[0]); - expect(results[0].transmitted).toBe(false); - done(); - }, (err) => { - fail('should not fail'); - done(); - }); + var promise = apns.send(data, devices); + expect(conn.pushNotification).toHaveBeenCalled(); + var args = conn.pushNotification.calls.first().args; + var notification = args[0]; + expect(notification.alert).toEqual(data.data.alert); + expect(notification.expiry).toEqual(data['expiration_time']); + var apnDevice = args[1] + expect(apnDevice.connIndex).toEqual(0); + expect(apnDevice.appIdentifier).toEqual('bundleId'); + done(); }); }); diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index cb08845c53..e21a9dbb21 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -29,9 +29,7 @@ describe('ParsePushAdapter', () => { var parsePushAdapter = new ParsePushAdapter(pushConfig); // Check ios var iosSender = parsePushAdapter.senderMap['ios']; - expect(iosSender).not.toBe(undefined); - expect(typeof iosSender.send).toEqual('function'); - expect(typeof iosSender.getConfiguration).toEqual('function'); + expect(iosSender instanceof APNS).toBe(true); // Check android var androidSender = parsePushAdapter.senderMap['android']; expect(androidSender instanceof GCM).toBe(true); diff --git a/src/APNS.js b/src/APNS.js index f95a6929bc..69389ce8f7 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -1,30 +1,9 @@ "use strict"; const Parse = require('parse/node').Parse; -const http = require('http2'); -const fs = require('fs'); -const path = require('path'); -const urlParse = require('url').parse; - -const DEV_PUSH_SERVER = 'api.development.push.apple.com'; -const PROD_PUSH_SERVER = 'api.push.apple.com'; - -const createRequestOptions = (opts, device, body) => { - let domain = opts.production === true ? PROD_PUSH_SERVER : DEV_PUSH_SERVER; - var options = urlParse(`https://${domain}/3/device/${device.deviceToken}`); - options.method = 'POST'; - options.headers = { - 'apns-expiration': opts.expiration || 0, - 'apns-priority': opts.priority || 10, - 'apns-topic': opts.bundleId || opts['apns-topic'], - 'content-length': body.length - }; - options.key = opts.key; - options.cert = opts.cert; - options.pfx = opts.pfx; - options.passphrase = opts.passphrase; - return Object.assign({}, options); -} +// TODO: apn does not support the new HTTP/2 protocal. It is fine to use it in V1, +// but probably we will replace it in the future. +const apn = require('apn'); /** * Create a new connection to the APN service. @@ -37,115 +16,170 @@ const createRequestOptions = (opts, device, body) => { * @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(options) { - - if (!Array.isArray(options)) { - options = [options]; +function APNS(args) { + // Since for ios, there maybe multiple cert/key pairs, + // typePushConfig can be an array. + let apnsArgsList = []; + if (Array.isArray(args)) { + apnsArgsList = apnsArgsList.concat(args); + } else if (typeof args === 'object') { + apnsArgsList.push(args); + } else { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'APNS Configuration is invalid'); } - let agents = {}; - - let optionsByBundle = options.reduce((memo, option) => { - try { - if (option.key && option.cert) { - option.key = fs.readFileSync(option.key); - option.cert = fs.readFileSync(option.cert); - } else if (option.pfx) { - option.pfx = fs.readFileSync(option.pfx); - } else { - throw 'Either cert AND key, OR pfx is required' - } - } catch(e) { - if (!process.env.NODE_ENV == 'test' || options.enforceCertificates) { - throw e; - } + this.conns = []; + for (let apnsArgs of apnsArgsList) { + let conn = new apn.Connection(apnsArgs); + if (!apnsArgs.bundleId) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'BundleId is mssing for %j', apnsArgs); + } + conn.bundleId = apnsArgs.bundleId; + // Set the priority of the conns, prod cert has higher priority + if (apnsArgs.production) { + conn.priority = 0; + } else { + conn.priority = 1; } - option.agent = new http.Agent({ - key: option.key, - cert: option.cert, - pfx: option.pfx, - passphrase: option.passphrase + + // Set apns client callbacks + conn.on('connected', () => { + console.log('APNS Connection %d Connected', conn.index); + }); + + conn.on('transmissionError', (errCode, notification, apnDevice) => { + handleTransmissionError(this.conns, errCode, notification, apnDevice); + }); + + conn.on('timeout', () => { + console.log('APNS Connection %d Timeout', conn.index); }); - memo[option.bundleId] = option; - return memo; - }, {}); - - let getConfiguration = (bundleIdentifier) => { - let configuration; - if (bundleIdentifier) { - configuration = optionsByBundle[bundleIdentifier]; - if (!configuration) { - return; + + conn.on('disconnected', () => { + console.log('APNS Connection %d Disconnected', conn.index); + }); + + conn.on('socketError', () => { + console.log('APNS Connection %d Socket Error', conn.index); + }); + + conn.on('transmitted', function(notification, device) { + if (device.callback) { + device.callback({ + notification: notification, + transmitted: true, + device: device + }); } + console.log('APNS Connection %d Notification transmitted to %s', conn.index, device.token.toString('hex')); + }); + + this.conns.push(conn); + } + // Sort the conn based on priority ascending, high pri first + this.conns.sort((s1, s2) => { + return s1.priority - s2.priority; + }); + // Set index of conns + for (let index = 0; index < this.conns.length; index++) { + this.conns[index].index = index; + } +} + +/** + * Send apns request. + * @param {Object} data The data we need to send, the format is the same with api request body + * @param {Array} devices A array of devices + * @returns {Object} A promise which is resolved immediately + */ +APNS.prototype.send = function(data, devices) { + let coreData = data.data; + let expirationTime = data['expiration_time']; + let notification = generateNotification(coreData, expirationTime); + + let promises = devices.map((device) => { + let qualifiedConnIndexs = chooseConns(this.conns, device); + // We can not find a valid conn, just ignore this device + if (qualifiedConnIndexs.length == 0) { + return Promise.resolve({ + transmitted: false, + result: {error: 'No connection available'} + }); } - if (!configuration) { - configuration = options[0]; + let conn = this.conns[qualifiedConnIndexs[0]]; + let apnDevice = new apn.Device(device.deviceToken); + apnDevice.connIndex = qualifiedConnIndexs[0]; + // Add additional appIdentifier info to apn device instance + if (device.appIdentifier) { + apnDevice.appIdentifier = device.appIdentifier; } - return configuration; + return new Promise((resolve, reject) => { + apnDevice.callback = resolve; + conn.pushNotification(notification, apnDevice); + }); + }); + return Parse.Promise.when(promises); +} + +function handleTransmissionError(conns, errCode, notification, apnDevice) { + // This means the error notification is not in the cache anymore or the recepient is missing, + // we just ignore this case + if (!notification || !apnDevice) { + return } - /** - * Send apns request. - * @param {Object} data The data we need to send, the format is the same with api request body - * @param {Array} devices A array of device tokens - * @returns {Object} A promises that resolves with each notificaiton sending promise - */ - let send = function(data, devices) { - // Make sure devices are in an array - if (!Array.isArray(devices)) { - devices = [devices]; - } + // If currentConn can not send the push notification, we try to use the next available conn. + // Since conns is sorted by priority, the next conn means the next low pri conn. + // If there is no conn available, we give up on sending the notification to that device. + let qualifiedConnIndexs = chooseConns(conns, apnDevice); + let currentConnIndex = apnDevice.connIndex; - let coreData = data.data; - let expirationTime = data['expiration_time']; - let notification = generateNotification(coreData); - let notificationString = JSON.stringify(notification); - let buffer = new Buffer(notificationString); - - let promises = devices.map((device) => { - return new Promise((resolve, reject) => { - let configuration = getConfiguration(device.appIdentifier); - if (!configuration) { - return Promise.reject({ - status: -1, - device: device, - response: {"error": "No configuration set for that appIdentifier"}, - transmitted: false - }) - } - configuration = Object.assign({}, configuration, {expiration: expirationTime }) - let requestOptions = createRequestOptions(configuration, device, buffer); - let req = configuration.agent.request(requestOptions, (response) => { - response.setEncoding('utf8'); - var chunks = ""; - response.on('data', (chunk) => { - chunks+=chunk; - }); - response.on('end', () => { - let body; - try{ - body = JSON.parse(chunks); - } catch (e) { - body = {}; - } - resolve({ status: response.statusCode, - response: body, - headers: response.headers, - device: device, - transmitted: response.statusCode == 200 }); - }); - }); - req.write(buffer); - req.end(); + let newConnIndex = -1; + // Find the next element of currentConnIndex in qualifiedConnIndexs + for (let index = 0; index < qualifiedConnIndexs.length - 1; index++) { + if (qualifiedConnIndexs[index] === currentConnIndex) { + newConnIndex = qualifiedConnIndexs[index + 1]; + break; + } + } + // There is no more available conns, we give up in this case + if (newConnIndex < 0 || newConnIndex >= conns.length) { + if (apnDevice.callback) { + apnDevice.callback({ + response: {error: `APNS can not find vaild connection for ${apnDevice.token}`, code: errCode}, + status: errCode, + transmitted: false }); - }); - return Promise.all(promises); + } + return; } - return Object.freeze({ - send: send, - getConfiguration: getConfiguration - }) + let newConn = conns[newConnIndex]; + // Update device conn info + apnDevice.connIndex = newConnIndex; + // Use the new conn to send the notification + newConn.pushNotification(notification, apnDevice); +} + +function chooseConns(conns, device) { + // If device does not have appIdentifier, all conns maybe proper connections. + // Otherwise we try to match the appIdentifier with bundleId + let qualifiedConns = []; + for (let index = 0; index < conns.length; index++) { + let conn = conns[index]; + // If the device we need to send to does not have + // appIdentifier, any conn could be a qualified connection + if (!device.appIdentifier || device.appIdentifier === '') { + qualifiedConns.push(index); + continue; + } + if (device.appIdentifier === conn.bundleId) { + qualifiedConns.push(index); + } + } + return qualifiedConns; } /** @@ -154,12 +188,12 @@ function APNS(options) { * @returns {Object} A apns notification */ function generateNotification(coreData, expirationTime) { + let notification = new apn.notification(); let payload = {}; - let notification = {}; for (let key in coreData) { switch (key) { case 'alert': - notification.alert = coreData.alert; + notification.setAlertText(coreData.alert); break; case 'badge': notification.badge = coreData.badge; @@ -168,10 +202,9 @@ function generateNotification(coreData, expirationTime) { notification.sound = coreData.sound; break; case 'content-available': + notification.setNewsstandAvailable(true); let isAvailable = coreData['content-available'] === 1; - if (isAvailable) { - notification['content-available'] = 1; - } + notification.setContentAvailable(isAvailable); break; case 'category': notification.category = coreData.category; @@ -181,11 +214,14 @@ function generateNotification(coreData, expirationTime) { break; } } - payload.aps = notification; - return payload; + notification.payload = payload; + notification.expiry = expirationTime; + return notification; } if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { APNS.generateNotification = generateNotification; + APNS.chooseConns = chooseConns; + APNS.handleTransmissionError = handleTransmissionError; } module.exports = APNS; From 18781f1f82c517f141b8465359b511de28d43153 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Mon, 14 Mar 2016 10:20:24 -0400 Subject: [PATCH 8/8] Removes _PushStatus from system classes, uses direct DB access to write --- spec/PushController.spec.js | 12 +++++++++++ src/Schema.js | 2 +- src/pushStatusHandler.js | 42 ++++++++++++++++++++++++------------- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/spec/PushController.spec.js b/spec/PushController.spec.js index f776fb9dbd..358c17e401 100644 --- a/spec/PushController.spec.js +++ b/spec/PushController.spec.js @@ -283,11 +283,18 @@ describe('PushController', () => { Parse.Object.saveAll(installations).then(() => { return pushController.sendPush(payload, {}, config, auth); }).then((result) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }).then(() => { let query = new Parse.Query('_PushStatus'); return query.find({useMasterKey: true}); }).then((results) => { expect(results.length).toBe(1); let result = results[0]; + expect(result.createdAt instanceof Date).toBe(true); expect(result.get('source')).toEqual('rest'); expect(result.get('query')).toEqual(JSON.stringify({})); expect(result.get('payload')).toEqual(payload.data); @@ -300,6 +307,11 @@ describe('PushController', () => { expect(result.get('failedPerType')).toEqual({ 'android': 5 // android }); + // Try to get it without masterKey + let query = new Parse.Query('_PushStatus'); + return query.find(); + }).then((results) => { + expect(results.length).toBe(0); done(); }); diff --git a/src/Schema.js b/src/Schema.js index f05aced043..adf197e8f2 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -91,7 +91,7 @@ var requiredColumns = { _Role: ["name", "ACL"] } -const systemClasses = ['_User', '_Installation', '_Role', '_Session', '_Product', '_PushStatus']; +const systemClasses = ['_User', '_Installation', '_Role', '_Session', '_Product']; // 10 alpha numberic chars + uppercase const userIdRegex = /^[a-zA-Z0-9]{10}$/; diff --git a/src/pushStatusHandler.js b/src/pushStatusHandler.js index 362ddb761c..465cc0c6fa 100644 --- a/src/pushStatusHandler.js +++ b/src/pushStatusHandler.js @@ -1,13 +1,20 @@ -import RestWrite from './RestWrite'; -import { md5Hash } from './cryptoUtils'; +import { md5Hash, newObjectId } from './cryptoUtils'; export default function pushStatusHandler(config) { let initialPromise; let pushStatus; + + let collection = function() { + return config.database.adaptiveCollection('_PushStatus'); + } + let setInitial = function(body, where, options = {source: 'rest'}) { + let now = new Date(); let object = { - pushTime: (new Date()).toISOString(), + objectId: newObjectId(), + pushTime: now.toISOString(), + _created_at: now, query: JSON.stringify(where), payload: body.data, source: options.source, @@ -16,21 +23,27 @@ export default function pushStatusHandler(config) { status: "pending", numSent: 0, pushHash: md5Hash(JSON.stringify(body.data)), - ACL: new Parse.ACL() // lockdown! + // lockdown! + _wperm: [], + _rperm: [] } - let restWrite = new RestWrite(config, {isMaster: true},'_PushStatus',null, object); - initialPromise = restWrite.execute().then((res) => { - pushStatus = res.response; + initialPromise = collection().then((collection) => { + return collection.insertOne(object); + }).then((res) => { + pushStatus = { + objectId: object.objectId + }; return Promise.resolve(pushStatus); - }); + }) return initialPromise; } let setRunning = function() { return initialPromise.then(() => { - let restWrite = new RestWrite(config, {isMaster: true}, '_PushStatus', {status:"pending", objectId: pushStatus.objectId}, {status: "running"}); - return restWrite.execute(); - }) + return collection(); + }).then((collection) => { + return collection.updateOne({status:"pending", objectId: pushStatus.objectId}, {$set: {status: "running"}}); + }); } let complete = function(results) { @@ -63,9 +76,10 @@ export default function pushStatusHandler(config) { } return initialPromise.then(() => { - let restWrite = new RestWrite(config, {isMaster: true}, '_PushStatus', {status:"running", objectId: pushStatus.objectId}, update); - return restWrite.execute(); - }) + return collection(); + }).then((collection) => { + return collection.updateOne({status:"running", objectId: pushStatus.objectId}, {$set: update}); + }); } return Object.freeze({