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..7dc02d43c8 100644 --- a/spec/Parse.Push.spec.js +++ b/spec/Parse.Push.spec.js @@ -1,20 +1,23 @@ +'use strict'; describe('Parse.Push', () => { it('should properly send push', (done) => { 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 +59,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/PushController.spec.js b/spec/PushController.spec.js index 9255c5c985..358c17e401 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 @@ -105,9 +129,9 @@ describe('PushController', () => { }).toThrow(); done(); }); - + it('properly increment badges', (done) => { - + var payload = {data:{ alert: "Hello World!", badge: "Increment", @@ -122,7 +146,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 +154,7 @@ describe('PushController', () => { installation.set("deviceType", "android"); installations.push(installation); } - + var pushAdapter = { send: function(body, installations) { var badge = body.data.badge; @@ -142,23 +166,20 @@ describe('PushController', () => { expect(installation.badge).toBeUndefined(); } }) - return Promise.resolve({ - body: body, - installations: installations - }) + return successfulTransmissions(body, installations); }, getValidPushTypes: function() { 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 +188,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 +207,7 @@ describe('PushController', () => { installation.set("deviceType", "ios"); installations.push(installation); } - + var pushAdapter = { send: function(body, installations) { var badge = body.data.badge; @@ -194,23 +215,20 @@ describe('PushController', () => { expect(installation.badge).toEqual(badge); expect(1).toEqual(installation.badge); }) - return Promise.resolve({ - body: body, - installations: installations - }) + return successfulTransmissions(body, installations); }, getValidPushTypes: function() { 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,29 +237,106 @@ describe('PushController', () => { fail("should not fail"); done(); }); - + }); - + + 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, + }} + + var pushAdapter = { + send: function(body, installations) { + return successfulIOS(body, installations); + }, + getValidPushTypes: function() { + return ["ios"]; + } + } + + var config = new Config(Parse.applicationId); + var auth = { + isMaster: true + } + + var pushController = new PushController(pushAdapter, Parse.applicationId); + 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); + 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 + }); + // Try to get it without masterKey + let query = new Parse.Query('_PushStatus'); + return query.find(); + }).then((results) => { + expect(results.length).toBe(0); + 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(); + return successfulTransmissions(body, installations); }, getValidPushTypes: function() { return ["ios"]; } } - + var config = new Config(Parse.applicationId); var auth = { isMaster: true } - + let where = { 'deviceToken': { '$inQuery': { diff --git a/src/APNS.js b/src/APNS.js index 500be9e23f..69389ce8f7 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -66,6 +66,13 @@ function APNS(args) { }); 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')); }); @@ -91,11 +98,15 @@ 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({ + transmitted: false, + result: {error: 'No connection available'} + }); } let conn = this.conns[qualifiedConnIndexs[0]]; let apnDevice = new apn.Device(device.deviceToken); @@ -104,13 +115,15 @@ 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 = resolve; + 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) { @@ -133,7 +146,13 @@ 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({ + response: {error: `APNS can not find vaild connection for ${apnDevice.token}`, code: errCode}, + status: errCode, + transmitted: false + }); + } return; } 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 c953d15763..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']; @@ -19,7 +22,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 +38,7 @@ export class ParsePushAdapter extends PushAdapter { } } } - + getValidPushTypes() { return this.validPushTypes; } @@ -43,18 +46,21 @@ export class ParsePushAdapter extends PushAdapter { static classifyInstallations(installations, validTypes) { return classifyInstallations(installations, validTypes) } - + send(data, installations) { let deviceMap = classifyInstallations(installations, this.validPushTypes); let sendPromises = []; 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/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/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 158963828d..efe9a750e8 100644 --- a/src/Controllers/PushController.js +++ b/src/Controllers/PushController.js @@ -6,6 +6,7 @@ import { PushAdapter } from '../Adapters/Push/PushAdapter'; import deepcopy from 'deepcopy'; import features from '../features'; import RestQuery from '../RestQuery'; +import pushStatusHandler from '../pushStatusHandler'; const FEATURE_NAME = 'push'; const UNSUPPORTED_BADGE_KEY = "unsupported"; @@ -38,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, @@ -65,7 +66,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,38 +82,58 @@ export class PushController extends AdaptableController { }) } } - - return badgeUpdate().then(() => { + let pushStatus = pushStatusHandler(config); + return Promise.resolve().then(() => { + return pushStatus.setInitial(body, where); + }).then(() => { + return badgeUpdate(); + }).then(() => { return rest.find(config, auth, '_Installation', where); }).then((response) => { - 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]); - }); - return Promise.all(promises); - } - return pushAdapter.send(body, response.results); + pushStatus.setRunning(); + return this.sendToAdapter(body, response.results, pushStatus, config); + }).then((results) => { + return pushStatus.complete(results); }); } + 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; + }, {}); + + // 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); + } + /** * Get expiration time from the request body. * @param {Object} request A request object diff --git a/src/GCM.js b/src/GCM.js index a13a67518e..e3df597976 100644 --- a/src/GCM.js +++ b/src/GCM.js @@ -22,8 +22,29 @@ function GCM(args) { * @returns {Object} A promise which is resolved after we get results from gcm */ GCM.prototype.send = function(data, devices) { - let pushId = cryptoUtils.newObjectId(); - let timeStamp = Date.now(); + // 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,33 +52,51 @@ GCM.prototype.send = function(data, devices) { 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(); - }); - 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); } /** @@ -68,10 +107,9 @@ GCM.prototype.send = function(data, devices) { * @param {Number|undefined} expirationTime A number whose format is the Unix Epoch or undefined * @returns {Object} A promise which is resolved after we get results from gcm */ -function generateGCMPayload(coreData, pushId, timeStamp, expirationTime) { +function generateGCMPayload(coreData, timeStamp, expirationTime) { let payloadData = { 'time': new Date(timeStamp).toISOString(), - 'push_id': pushId, 'data': JSON.stringify(coreData) } let payload = { diff --git a/src/RestQuery.js b/src/RestQuery.js index 530a7b8980..8cfd26df86 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'; @@ -170,7 +171,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) => { diff --git a/src/RestWrite.js b/src/RestWrite.js index ca4afcf508..8caaf2ab53 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'); @@ -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) => { diff --git a/src/Schema.js b/src/Schema.js index ffb7b088b1..adf197e8f2 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -67,7 +67,22 @@ 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 webui + "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'}, + "numFailed": {type:'Number'}, + "pushHash": {type:'String'}, + "errorMessage": {type:'Object'}, + "sentPerType": {type:'Object'}, + "failedPerType":{type:'Object'}, } }; @@ -76,6 +91,8 @@ var requiredColumns = { _Role: ["name", "ACL"] } +const systemClasses = ['_User', '_Installation', '_Role', '_Session', '_Product']; + // 10 alpha numberic chars + uppercase const userIdRegex = /^[a-zA-Z0-9]{10}$/; // Anything that start with role @@ -127,13 +144,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 +296,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 +311,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 +339,7 @@ class Schema { }); return Promise.all(promises); }) - .then(() => { + .then(() => { return this.setPermissions(className, classLevelPermissions) }) .then(() => { return mongoSchemaToSchemaAPIResponse(mongoObject.result) }); @@ -697,7 +709,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 +898,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 +915,5 @@ module.exports = { buildMergedSchemaObject: buildMergedSchemaObject, mongoFieldTypeToSchemaAPIType: mongoFieldTypeToSchemaAPIType, mongoSchemaToSchemaAPIResponse, + systemClasses, }; 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'); +} diff --git a/src/pushStatusHandler.js b/src/pushStatusHandler.js new file mode 100644 index 0000000000..465cc0c6fa --- /dev/null +++ b/src/pushStatusHandler.js @@ -0,0 +1,90 @@ +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 = { + objectId: newObjectId(), + pushTime: now.toISOString(), + _created_at: now, + 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)), + // lockdown! + _wperm: [], + _rperm: [] + } + 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(() => { + return collection(); + }).then((collection) => { + return collection.updateOne({status:"pending", objectId: pushStatus.objectId}, {$set: {status: "running"}}); + }); + } + + 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(() => { + return collection(); + }).then((collection) => { + return collection.updateOne({status:"running", objectId: pushStatus.objectId}, {$set: update}); + }); + } + + return Object.freeze({ + setInitial, + setRunning, + complete + }) +}