diff --git a/spec/ScheduledPushRouter.spec.js b/spec/ScheduledPushRouter.spec.js new file mode 100644 index 0000000000..b7fb84c304 --- /dev/null +++ b/spec/ScheduledPushRouter.spec.js @@ -0,0 +1,355 @@ +'use strict'; + +const request = require('../lib/request'); + +describe('Scheduled Push Router', () => { + // the number of milliseconds to wait for the push to save before continueing + // This MAY need to be increase if tests fail on a slower box + // 5 ms results in 1/3 failing + // 10 ms results in all passing on a fast box + // 50 ms is a 5x buffer for a slower box + const delayToSave = 50; + + const delayPromise = delay => { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); + }; + + const setup = async () => { + // const sendToInstallationSpy = jasmine.createSpy(); + + const pushAdapter = { + send: function() { + return Promise.resolve({ + err: null, + transmitted: true, + }); + }, + getValidPushTypes: function() { + return ['ios', 'android']; + }, + }; + + await reconfigureServer({ + scheduledPush: true, + appId: Parse.applicationId, + masterKey: Parse.masterKey, + serverURL: Parse.serverURL, + push: { + adapter: pushAdapter, + }, + }); + const installations = []; + while (installations.length != 10) { + const 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); + } + await Parse.Object.saveAll(installations); + }; + + const queryPushStatus = async () => { + const response = await request({ + url: 'http://localhost:8378/1/classes/_PushStatus', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + const body = response.data; + return body; + }; + + const triggerPushTick = async () => { + const response = await request({ + url: 'http://localhost:8378/1/push/sendScheduledPushes', + method: 'POST', + json: true, + body: { + overrideNow: '2019-01-05T01:01:02Z', // Mock that now is 2 seconds into Jan 5, 2019 + }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + + const body = response.data; + // wait for the push to save + await delayPromise(delayToSave); + + return body; + }; + + const yesPush = async data => { + await _pushShouldMatchAfterSweep(data, 'succeeded'); + }; + + const noPushFailed = async data => { + await _pushShouldMatchAfterSweep(data, 'failed'); + }; + + const noPushScheduled = async data => { + await _pushShouldMatchAfterSweep(data, 'scheduled'); + }; + + const _pushShouldMatchAfterSweep = async (data, match) => { + await setup(); + const defaultObject = { + where: { + deviceType: 'ios', + }, + data: { + alert: 'Hello Everyone!', + }, + }; + const merged = { ...defaultObject, ...data }; + // Schedule the push + await Parse.Push.send(merged, { useMasterKey: true }); + + const before = await queryPushStatus(); + expect(before.results.length).toEqual(1); + expect(before.results[0].status).toEqual('scheduled'); + + // trigger the function that should send all of the sheduled pushes + await triggerPushTick(); + + const after = await queryPushStatus(); + expect(after.results.length).toEqual(1); + expect(after.results[0].status).toEqual(match); + }; + + describe('Should send push', () => { + describe('because push_time is', () => { + it('1 second in the past', async () => { + await yesPush({ + push_time: '2019-01-05T01:01:01Z', // send push no eariler than 1 seconds in the past + }); + }); + + it('4 days in the past', async () => { + await yesPush({ + push_time: '2019-01-01T01:01:01Z', // send push no eariler than 4 days in the past + }); + }); + + it('5 years in the past', async () => { + await yesPush({ + push_time: '2014-01-05T01:01:01Z', // send push no eariler than 5 years in the past + }); + }); + + it('100 years in the past', async () => { + await yesPush({ + push_time: '1919-01-05T01:01:01Z', // send push no eariler than 100 years in the past + }); + }); + }); + + describe('because push_time is in past and experation expiration_interval is', () => { + it('10 seconds in the future', async () => { + await yesPush({ + push_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + expiration_interval: 11, // expire 11 seconds after the start of eligibility + }); + }); + + it('2 days into the future', async () => { + await yesPush({ + push_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + expiration_interval: 60 * 60 * 24 * 2, // expire 7 day after the start of eligibility + }); + }); + + it('5 years in future', async () => { + await yesPush({ + push_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + expiration_interval: 60 * 60 * 24 * 365 * 5 + 60 * 60 * 24 * 2, // expire 5 years and 2 days after the start of eligibility (leap years...) + }); + }); + + it('5 years in future', async () => { + await yesPush({ + push_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + expiration_interval: 60 * 60 * 24 * 365 * 5, // expire 5 years after the start of eligibility + }); + }); + }); + + describe('because push_time is in past and experation expiration_time is', () => { + it('1 seconds in the future', async () => { + await yesPush({ + push_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + expiration_time: '2019-01-06T01:01:03Z', // one second in the future + }); + }); + + it('1 seconds in the future (expiration_time local timezone)', async () => { + await yesPush({ + push_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + expiration_time: '2019-01-05T18:01:03-07:00', // one second in the future (local) + }); + }); + + it('1 seconds in the future (push_time local timezone)', async () => { + await yesPush({ + push_time: '2019-01-04T18:01:01-07:00', // send push no eariler than one second ago (local) + expiration_time: '2019-01-06T01:01:03Z', // one second in the future + }); + }); + + it('1 seconds in the future (push_time and expiration_time local timezone)', async () => { + await yesPush({ + push_time: '2019-01-04T18:01:01-07:00', // send push no eariler than one second ago + expiration_time: '2019-01-05T18:01:03-07:00', // one second in the future (local) + }); + }); + + it('4 days in the future (missing time)', async () => { + await yesPush({ + push_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + expiration_time: '2019-01-09', // expire after 4 days in the future + }); + }); + + it('4 days in the future', async () => { + await yesPush({ + push_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + expiration_time: '2019-01-09T01:01:03Z', // expire after 4 days in the future + }); + }); + + it('100 years in the future', async () => { + await yesPush({ + push_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + expiration_time: '2119-01-05T01:01:12Z', // expire after 100 years days in the future + }); + }); + }); + }); + + describe('Should not send push', () => { + describe('because push_time is', () => { + it('10 seconds in the future', async () => { + await noPushScheduled({ + push_time: '2019-01-05T01:01:12Z', // send push no eariler than 10 seconds in the future + }); + }); + + it('4 days in the future', async () => { + await noPushScheduled({ + push_time: '2019-01-09T01:01:01Z', // send push no eariler than 4 days in the future + }); + }); + + it('5 years in the future', async () => { + await noPushScheduled({ + push_time: '2024-01-05T01:01:01Z', // send push no eariler than 5 in the future + }); + }); + + it('100 years in the future', async () => { + await noPushScheduled({ + push_time: '2119-01-05T01:01:01Z', // send push no eariler than 100 years in the future + }); + }); + }); + + describe('because expiration_interval expired', () => { + it('1 second ago', async () => { + await noPushFailed({ + push_time: '2019-01-05T01:01:00Z', // send push no eariler than two seconds ago + expiration_interval: 1, // expire 1 second after the pushTime, 1 second ago + }); + }); + + it('4 days ago', async () => { + await noPushFailed({ + push_time: '2019-01-01T01:01:01Z', // send push no eariler than 4 days ago + expiration_interval: 60, // expire 1 minute after the pushTime, 4 days ago + }); + }); + + it('5 years ago', async () => { + await noPushFailed({ + push_time: '2014-01-05T01:01:01Z', // send push no eariler than 5 years ago + expiration_interval: 60, // expire 1 minute after the pushTime, 5 years ago + }); + }); + }); + + describe('because expiration_time expired', () => { + it('before the push_time (with push_time in past)', async () => { + await noPushFailed({ + push_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + expiration_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + }); + }); + + it('before the push_time (with push_time in future)', async () => { + await noPushScheduled({ + push_time: '2019-01-05T01:01:03Z', // send push no eariler than one second in the future + expiration_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + }); + }); + + it('one second in the past', async () => { + await noPushFailed({ + push_time: '2019-01-01T01:01:01Z', // send push no eariler than 4 days ago + expiration_time: '2019-01-05T01:01:01Z', // expire one second ago + }); + }); + + it('1 seconds in the past (expiration_time local timezone)', async () => { + await noPushFailed({ + push_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + expiration_time: '2019-01-04T18:01:01-07:00', // one second in the past (local) + }); + }); + + it('1 seconds in the past (push_time local timezone)', async () => { + await noPushFailed({ + push_time: '2019-01-04T18:01:01-07:00', // send push no eariler than one second ago (local) + expiration_time: '2019-01-05T01:01:01Z', // one second in the past + }); + }); + + it('1 seconds in the past (push_time and expiration_time local timezone)', async () => { + await noPushFailed({ + push_time: '2019-01-04T18:01:01-07:00', // send push no eariler than one second ago + expiration_time: '2019-01-04T18:01:01-07:00', // one second in the past (local) + }); + }); + + it('2 days in the past (missing time)', async () => { + await noPushFailed({ + push_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + expiration_time: '2019-01-03', // expire after 2 days in the past + }); + }); + + it('2 days in the past', async () => { + await noPushFailed({ + push_time: '2019-01-01T01:01:01Z', // send push no eariler than 4 days ago + expiration_time: '2019-01-03T01:01:01Z', // expire 2 days ago + }); + }); + + it('5 years in the past', async () => { + await noPushFailed({ + push_time: '2019-01-05T01:01:01Z', // send push no eariler than one second ago + expiration_time: '2014-01-01T01:01:01Z', // expire 5 years ago + }); + }); + }); + }); +}); diff --git a/src/Controllers/PushController.js b/src/Controllers/PushController.js index 4739235810..d1cdd532e6 100644 --- a/src/Controllers/PushController.js +++ b/src/Controllers/PushController.js @@ -144,6 +144,80 @@ export class PushController { }); } + sendScheduledPush(pushObject, config, auth, now = new Date()) { + if (!config.hasPushSupport) { + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Missing push configuration' + ); + } + + // translate parseObject to body + const body = {}; + if (pushObject.has('pushTime')) body.push_time = pushObject.get('pushTime'); + if (pushObject.has('expiry')) + body.expiration_time = pushObject.get('expiry'); + if (pushObject.has('expiration_interval')) + body.expiration_interval = pushObject.get('expiration_interval'); + if (pushObject.has('payload')) + body.data = JSON.parse(pushObject.get('payload')); + + const where = JSON.parse(pushObject.get('query')); + + // Replace the expiration_time and push_time with a valid Unix epoch milliseconds time + body.expiration_time = PushController.getExpirationTime(body); + body.expiration_interval = PushController.getExpirationInterval(body); + if (body.expiration_time && body.expiration_interval) { + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Both expiration_time and expiration_interval cannot be set' + ); + } + + // calculate and set the expiration_time for APNS and GCM to use + if ( + body.expiration_interval && + !Object.prototype.hasOwnProperty.call(body, 'push_time') + ) { + const ttlMs = body.expiration_interval * 1000; + body.expiration_time = new Date(now.valueOf() + ttlMs).valueOf(); + } + + const pushTime = PushController.getPushTime(body); + if (pushTime && pushTime.date !== 'undefined') { + body['push_time'] = PushController.formatPushTime(pushTime); + } + + // TODO: currently we increment the badge when we schedule the push. + // We should consider waiting to increment the badge until the + // actual push is sent here. This would prevent a "ghost badge" + // where the user sees a "1" in their app but the notifcation + // hasn't been sent yet. Until we make that breaking change + // lets comment this out to make sure we dont increment the + // badge twice. + const pushStatus = pushStatusHandler(config, pushObject.id); + return ( + Promise.resolve() + // .then(() => { + // return badgeUpdate(); + // }) + .then(() => { + return config.pushControllerQueue.enqueue( + body, + where, + config, + auth, + pushStatus + ); + }) + .catch(err => { + return pushStatus.fail(err).then(() => { + throw err; + }); + }) + ); + } + /** * Get expiration time from the request body. * @param {Object} request A request object diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index d66f2f99a2..9e3e4dd18d 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -87,6 +87,7 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({ }, _PushStatus: { pushTime: { type: 'String' }, + pushDate: { type: 'Date' }, // to improve speed for tolling Scheduled Pushs source: { type: 'String' }, // rest or webui query: { type: 'String' }, // the stringified JSON query payload: { type: 'String' }, // the stringified JSON payload, diff --git a/src/ParseServer.js b/src/ParseServer.js index 4adc8b4a49..4eead20050 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -29,6 +29,7 @@ import { LogsRouter } from './Routers/LogsRouter'; import { ParseLiveQueryServer } from './LiveQuery/ParseLiveQueryServer'; import { PublicAPIRouter } from './Routers/PublicAPIRouter'; import { PushRouter } from './Routers/PushRouter'; +import { ScheduledPushRouter } from './Routers/ScheduledPushRouter'; import { CloudCodeRouter } from './Routers/CloudCodeRouter'; import { RolesRouter } from './Routers/RolesRouter'; import { SchemasRouter } from './Routers/SchemasRouter'; @@ -218,6 +219,7 @@ class ParseServer { new FunctionsRouter(), new SchemasRouter(), new PushRouter(), + new ScheduledPushRouter(), new LogsRouter(), new IAPValidationRouter(), new FeaturesRouter(), diff --git a/src/Routers/ScheduledPushRouter.js b/src/Routers/ScheduledPushRouter.js new file mode 100644 index 0000000000..aea960c5c9 --- /dev/null +++ b/src/Routers/ScheduledPushRouter.js @@ -0,0 +1,109 @@ +import PromiseRouter from '../PromiseRouter'; +import * as middleware from '../middlewares'; +import { Parse } from 'parse/node'; +import { pushStatusHandler } from '../StatusHandler'; + +export class ScheduledPushRouter extends PromiseRouter { + mountRoutes() { + this.route( + 'POST', + '/push/sendScheduledPushes', + middleware.promiseEnforceMasterKeyAccess, + ScheduledPushRouter.handlePOST + ); + } + + // always returns { result: true } + static handlePOST(req) { + if (req.auth.isReadOnly) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to trigger scheduled push notifications." + ); + } + const pushController = req.config.pushController; + if (!pushController) { + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Push controller is not set' + ); + } + + let now; + if ( + req.body && + req.body.overrideNow && + typeof req.body.overrideNow === 'string' + ) { + now = new Date(req.body.overrideNow); + } else { + now = new Date(); + } + + const query = new Parse.Query('_PushStatus'); + + query.lessThan('pushDate', now); + query.equalTo('status', 'scheduled'); + + query.each( + async pushObject => { + if (pushObject.has('expiration_interval') && pushObject.has('expiry')) { + // Invalid configuration, fail the status to keep a clean "scheduled" query + const pushStatus = pushStatusHandler(req.config, pushObject.id); + pushStatus.fail( + 'Invalid Push: only impliment expiration_interval or expiry, not both' + ); + } else if ( + pushObject.has('expiry') || + pushObject.has('expiration_interval') + ) { + let expDate; + + if (pushObject.has('expiry')) { + // Has an expiration date + expDate = pushObject.get('expiry'); + } else if (pushObject.has('expiration_interval')) { + // Has an expiration Interval + // calculate the expiration date from the pushDate + const pushDate = pushObject.get('pushDate'); + const expInterval = pushObject.get('expiration_interval'); + expDate = pushDate.setSeconds(pushDate.getSeconds() + expInterval); + } + + if (expDate < now) { + const pushStatus = pushStatusHandler(req.config, pushObject.id); + pushStatus.fail('Expired on ' + expDate); + } else { + ScheduledPushRouter.sendPushFromPushStatus(pushObject, req); + } + } else { + // No expiration Date + ScheduledPushRouter.sendPushFromPushStatus(pushObject, req); + } + }, + { useMasterKey: true } + ); + + // return resolved promise + return Promise.resolve({ + response: { + result: true, + }, + }); + } + + static sendPushFromPushStatus(object, req) { + const pushController = req.config.pushController; + + pushController + .sendScheduledPush(object, req.config, req.auth) + .catch(err => { + req.config.loggerController.error( + `_PushStatus : error while sending push`, + err + ); + }); + } +} + +export default ScheduledPushRouter; diff --git a/src/StatusHandler.js b/src/StatusHandler.js index db8e816596..3c45bdeebf 100644 --- a/src/StatusHandler.js +++ b/src/StatusHandler.js @@ -147,10 +147,12 @@ export function pushStatusHandler(config, existingObjectId) { const setInitial = function(body = {}, where, options = { source: 'rest' }) { const now = new Date(); let pushTime = now.toISOString(); + let pushDate = now; let status = 'pending'; if (Object.prototype.hasOwnProperty.call(body, 'push_time')) { if (config.hasPushScheduledSupport) { pushTime = body.push_time; + pushDate = new Date(body.push_time); status = 'scheduled'; } else { logger.warn( @@ -179,6 +181,7 @@ export function pushStatusHandler(config, existingObjectId) { expiry: body.expiration_time, expiration_interval: body.expiration_interval, status: status, + pushDate: { iso: pushDate, __type: 'Date' }, numSent: 0, pushHash, // lockdown!