Showing with 473 additions and 163 deletions.
  1. +26 −0 docs/pushpayloads.schema.json
  2. +161 −64 lib/push.js
  3. +15 −11 lib/routes/account.js
  4. +4 −1 test/e2e/push_tests.js
  5. +62 −2 test/local/account_routes.js
  6. +205 −85 test/local/push_tests.js
@@ -25,6 +25,7 @@
"type":"object",
"anyOf":[
{ "$ref":"#/definitions/deviceConnected" },
{ "$ref":"#/definitions/deviceDisconnected" },
{ "$ref":"#/definitions/collectionsChanged" }
]
}
@@ -55,6 +56,31 @@
}
}
},
"deviceDisconnected":{
"type":"object",
"required":[
"data"
],
"properties":{
"command":{
"enum":[
"fxaccounts:device_disconnected"
]
},
"data":{
"type":"object",
"required":[
"id"
],
"properties":{
"id":{
"type":"string",
"description":"The id of the device who was disconnected remotely"
}
}
}
}
},
"collectionsChanged":{
"type":"object",
"required":[
@@ -12,9 +12,12 @@ var LOG_OP_PUSH_TO_DEVICES = 'push.pushToDevices'

var PUSH_PAYLOAD_SCHEMA_VERSION = 1
var PUSH_COMMANDS = {
DEVICE_CONNECTED: 'fxaccounts:device_connected'
DEVICE_CONNECTED: 'fxaccounts:device_connected',
DEVICE_DISCONNECTED: 'fxaccounts:device_disconnected'
}

var TTL_DEVICE_DISCONNECTED = 5 * 3600 // 5 hours

var reasonToEvents = {
accountVerify: {
send: 'push.account_verify.send',
@@ -47,6 +50,14 @@ var reasonToEvents = {
failed: 'push.device_connected.failed',
noCallback: 'push.device_connected.no_push_callback',
noKeys: 'push.device_connected.data_but_no_keys'
},
deviceDisconnected: {
send: 'push.device_disconnected.send',
success: 'push.device_disconnected.success',
resetSettings: 'push.device_disconnected.reset_settings',
failed: 'push.device_disconnected.failed',
noCallback: 'push.device_disconnected.no_push_callback',
noKeys: 'push.device_disconnected.data_but_no_keys'
}
}

@@ -83,11 +94,28 @@ module.exports = function (log, db) {
}
}

/**
* Copy sendPush authorized options from an existing options object
* to a new one
*
* @param options
*/
function filterOptions(options) {
var allowedProps = ['TTL', 'data']
return allowedProps.reduce(function(filtered, prop) {
if (options[prop]) {
filtered[prop] = options[prop]
}
return filtered
}, {})
}

return {
/**
* Notifies all devices that there was an update to the account
* Notifies all devices that there was an update to the account
*
* @param uid
* @param reason
* @promise
*/
notifyUpdate: function notifyUpdate(uid, reason) {
@@ -96,7 +124,7 @@ module.exports = function (log, db) {
},

/**
* Notifies all devices (except the one who joined) that a new device joined the account
* Notifies all devices (except the one who joined) that a new device joined the account
*
* @param uid
* @param deviceName
@@ -111,83 +139,152 @@ module.exports = function (log, db) {
deviceName: deviceName
}
}))
return this.pushToDevices(uid, 'deviceConnected', data, [currentDeviceId])
var options = { data: data, excludedDeviceIds: [currentDeviceId] }
return this.pushToDevices(uid, 'deviceConnected', options)
},

/**
* Notifies a device that it is now disconnected from the account
*
* @param uid
* @param idToDisconnect
* @promise
*/
notifyDeviceDisconnected: function notifyDeviceDisconnected(uid, idToDisconnect) {
var data = new Buffer(JSON.stringify({
version: PUSH_PAYLOAD_SCHEMA_VERSION,
command: PUSH_COMMANDS.DEVICE_DISCONNECTED,
data: {
id: idToDisconnect
}
}))
var options = { data: data, TTL: TTL_DEVICE_DISCONNECTED }
return this.pushToDevice(uid, idToDisconnect, 'deviceDisconnected', options)
},

/**
* Send a push notification with or without data to all the devices in the account (except the ones in the excludedDeviceIds)
*
* @param uid
* @param reason
* @param data
* @param excludedDeviceIds
* @param {Object} options
* @param {String} options.excludedDeviceIds
* @param {String} options.data
* @param {String} options.TTL (in seconds)
* @promise
*/
pushToDevices: function pushToDevices(uid, reason, data, excludedDeviceIds) {
var events = reasonToEvents[reason]
pushToDevices: function pushToDevices(uid, reason, options) {
options = options || {}
var self = this
return db.devices(uid).then(
function (devices) {
if (options.excludedDeviceIds) {
devices = devices.filter(function(device) {
return options.excludedDeviceIds.indexOf(device.id.toString('hex')) === -1
})
}
var pushOptions = filterOptions(options)
return self.sendPush(uid, devices, reason, pushOptions)
})
},

/**
* Send a push notification with or without data to one device in the account
*
* @param uid
* @param id
* @param reason
* @param {Object} options
* @param {String} options.data
* @param {String} options.TTL (in seconds)
* @promise
*/
pushToDevice: function pushToDevice(uid, id, reason, options) {
options = options || {}
var self = this
return db.devices(uid).then(
function (devices) {
return P.all(
devices.map(function(device) {
var deviceId = device.id.toString('hex')
for (var i = 0; i < devices.length; i++) {
if (devices[i].id.toString('hex') === id) {
var pushOptions = filterOptions(options)
return self.sendPush(uid, [devices[i]], reason, pushOptions)
}
}
return P.reject('Device id not found in devices')
})
},


if (excludedDeviceIds && excludedDeviceIds.indexOf(deviceId) !== -1) {
/**
* Send a push notification with or without data to a list of devices
*
* @param uid
* @param devices
* @param reason
* @param {Object} options
* @param {String} options.data
* @param {String} options.TTL (in seconds)
* @promise
*/
sendPush: function sendPush(uid, devices, reason, options) {
options = options || {}
var events = reasonToEvents[reason]
return P.all(
devices.map(function (device) {
var deviceId = device.id.toString('hex')

log.trace({
op: LOG_OP_PUSH_TO_DEVICES,
deviceId: deviceId,
pushCallback: device.pushCallback
})

if (device.pushCallback) {
// send the push notification
incrementPushAction(events.send)
var pushParams = { 'TTL': options.TTL || '0' }
if (options.data) {
if (!device.pushPublicKey || !device.pushAuthKey) {
reportPushError(new Error(ERR_DATA_BUT_NO_KEYS), deviceId)
incrementPushAction(events.noKeys)
return
}

log.trace({
op: LOG_OP_PUSH_TO_DEVICES,
deviceId: deviceId,
pushCallback: device.pushCallback
})

if (device.pushCallback) {
// send the push notification
incrementPushAction(events.send)
var pushParams = { 'TTL': '0' }
if (data) {
if (!device.pushPublicKey || !device.pushAuthKey) {
reportPushError(new Error(ERR_DATA_BUT_NO_KEYS), deviceId)
incrementPushAction(events.noKeys)
return
}
pushParams.userPublicKey = device.pushPublicKey
pushParams.userAuth = device.pushAuthKey
pushParams.payload = data
pushParams.userPublicKey = device.pushPublicKey
pushParams.userAuth = device.pushAuthKey
pushParams.payload = options.data
}
return webpush.sendNotification(device.pushCallback, pushParams)
.then(
function () {
incrementPushAction(events.success)
},
function (err) {
// 404 or 410 error from the push servers means
// the push settings need to be reset.
// the clients will check this and re-register push endpoints
if (err.statusCode === 404 || err.statusCode === 410) {
// reset device push configuration
// Warning: this method is called without any session tokens or auth validation.
device.pushCallback = ''
device.pushPublicKey = ''
device.pushAuthKey = ''
return db.updateDevice(uid, device.id, device).catch(function (err) {
reportPushError(err, deviceId)
}).then(function() {
incrementPushAction(events.resetSettings)
})
} else {
reportPushError(err, deviceId)
incrementPushAction(events.failed)
}
return webpush.sendNotification(device.pushCallback, pushParams)
.then(
function () {
incrementPushAction(events.success)
},
function (err) {
// 404 or 410 error from the push servers means
// the push settings need to be reset.
// the clients will check this and re-register push endpoints
if (err.statusCode === 404 || err.statusCode === 410) {
// reset device push configuration
// Warning: this method is called without any session tokens or auth validation.
device.pushCallback = ''
device.pushPublicKey = ''
device.pushAuthKey = ''
return db.updateDevice(uid, device.id, device).catch(function (err) {
reportPushError(err, deviceId)
}).then(function() {
incrementPushAction(events.resetSettings)
})
} else {
reportPushError(err, deviceId)
incrementPushAction(events.failed)
}
}
)
} else {
// keep track if there are any devices with no push urls.
reportPushError(new Error(ERR_NO_PUSH_CALLBACK), deviceId)
incrementPushAction(events.noCallback)
}
}))
})
)
} else {
// keep track if there are any devices with no push urls.
reportPushError(new Error(ERR_NO_PUSH_CALLBACK), deviceId)
incrementPushAction(events.noCallback)
}
}))
}
}
}
@@ -896,17 +896,21 @@ module.exports = function (
var sessionToken = request.auth.credentials
var uid = sessionToken.uid
var id = request.payload.id
db.deleteDevice(uid, id).then(
function (result) {
log.event('device:delete', request, {
uid: uid,
id: id,
timestamp: Date.now()
})
reply(result)
},
reply
)
push.notifyDeviceDisconnected(uid, id).then(deleteDbDevice, deleteDbDevice)

function deleteDbDevice() {
db.deleteDevice(uid, id).then(
function (result) {
log.event('device:delete', request, {
uid: uid,
id: id,
timestamp: Date.now()
})
reply(result)
},
reply
)
}
}
},
{
@@ -48,7 +48,10 @@ test(
})

var push = proxyquire('../../lib/push', {})(thisMockLog, mockDbResult)
push.pushToDevices(mockUid, 'accountVerify', new Buffer('foodata'))
var options = {
data: new Buffer('foodata')
}
push.pushToDevices(mockUid, 'accountVerify', options)

})
}