From 08d7727881c883e5d09dd47b7d3d705426595808 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 23 Jun 2016 00:19:37 +0200 Subject: [PATCH] Push notifications (#7682) * Fix Social Push notifications * Fix code formatting issues * Fix commented issues * Fix Syntax errors * update push notify dependency * specify push-notify version * change how apn key is loaded * feat(push-notifications): improve logging * feat(push-notifications): disable v2 push notifications * test(push-notifications): add unit tests and improve integration ones * fix(push-notifications): throw when required params are missing * fix(tests): correct descriptions and remove wrong comment * fix(push-notifications): trim APN key * fix(apn): log feedback only if it has data * fix(apn): load cert and key differently * fix(tests): correctly load apn during tests * download creds from S3 and create AWS lib * convert s3 buffer to a string * fix(apn): remove console.log and do not use cert twice * invert key and cert, disable failing test * invert key and cert --- common/locales/en/settings.json | 13 +- common/script/index.js | 3 - common/script/ops/addPushDevice.js | 41 ---- common/script/ops/index.js | 2 - config.json.example | 5 +- package.json | 2 +- .../user/DELETE-user_push_device.test.js | 32 +++ .../user/POST-user_addPushDevice.test.js | 35 ---- .../user/POST-user_push_device.test.js | 60 ++++++ test/api/v3/unit/libs/pushNotifications.js | 185 ++++++++++++++++++ test/common/ops/addPushDevice.js | 59 ------ .../controllers/api-v2/pushNotifications.js | 54 +---- website/server/controllers/api-v3/groups.js | 18 +- website/server/controllers/api-v3/members.js | 23 ++- .../controllers/api-v3/pushNotifications.js | 95 +++++++++ website/server/controllers/api-v3/quests.js | 17 +- website/server/controllers/api-v3/user.js | 26 --- .../controllers/top-level/dataexport.js | 8 +- website/server/libs/api-v3/aws.js | 7 + website/server/libs/api-v3/payments.js | 22 ++- .../server/libs/api-v3/pushNotifications.js | 100 +++++++--- website/server/models/challenge.js | 10 +- website/server/models/group.js | 17 +- website/server/models/pushDevice.js | 21 ++ website/server/models/user/schema.js | 17 +- website/views/options/settings.jade | 47 +++-- 26 files changed, 626 insertions(+), 293 deletions(-) delete mode 100644 common/script/ops/addPushDevice.js create mode 100644 test/api/v3/integration/user/DELETE-user_push_device.test.js delete mode 100644 test/api/v3/integration/user/POST-user_addPushDevice.test.js create mode 100644 test/api/v3/integration/user/POST-user_push_device.test.js create mode 100644 test/api/v3/unit/libs/pushNotifications.js delete mode 100644 test/common/ops/addPushDevice.js create mode 100644 website/server/controllers/api-v3/pushNotifications.js create mode 100644 website/server/libs/api-v3/aws.js create mode 100644 website/server/models/pushDevice.js diff --git a/common/locales/en/settings.json b/common/locales/en/settings.json index b25fbc9d23..e854bebd05 100644 --- a/common/locales/en/settings.json +++ b/common/locales/en/settings.json @@ -108,10 +108,12 @@ "emailNotifications": "Email Notifications", "wonChallenge": "You won a Challenge!", "newPM": "Received Private Message", + "newPMInfo": "New Message from <%= name %>: <%= message %>", "sentGems": "Sent gems!", "giftedGems": "Gifted Gems", - "giftedGemsInfo": "<%= amount %> Gems - by <%= name %>", + "giftedGemsInfo": "<%= name %> gifted you <%= amount %> Gems", "giftedSubscription": "Gifted Subscription", + "giftedSubscriptionInfo": "<%= name %> gifted you a <%= months %> Subscription", "invitedParty": "Invited To Party", "invitedGuild": "Invited To Guild", "importantAnnouncements": "Your account is inactive", @@ -126,6 +128,7 @@ "unsubscribedTextOthers": "You won't receive any other email from Habitica.", "unsubscribeAllEmails": "Check to Unsubscribe from Emails", "unsubscribeAllEmailsText": "By checking this box, I certify that I understand that by unsubscribing from all emails, Habitica will never be able to notify me via email about important changes to the site or my account.", + "unsubscribeAllPush": "Check to Unsubscribe from all Push Notifications", "correctlyUnsubscribedEmailType": "Correctly unsubscribed from \"<%= emailType %>\" emails.", "subscriptionRateText": "Recurring $<%= price %> USD every <%= months %> months", "recurringText": "recurring", @@ -138,8 +141,8 @@ "promoCode": "Promo Code", "promoCodeApplied": "Promo Code Applied! Check your inventory", "promoPlaceholder": "Enter Promotion Code", - "couponText": "We sometimes have events and give out coupon codes for special gear. (eg, those who stop by our Wondercon booth)", "displayInviteToPartyWhenPartyIs1": "Display Invite To Party button when party has 1 member.", + "couponText": "We sometimes have events and give out coupon codes for special gear. (eg, those who stop by our Wondercon booth)", "saveCustomDayStart": "Save Custom Day Start", "registration": "Registration", "addLocalAuth": "Add local authentication:", @@ -152,8 +155,11 @@ "invalidUrl": "invalid url", "invalidEnabled": "the \"enabled\" parameter should be a boolean", "regIdRequired": "RegId is required", + "invalidPushClient": "Invalid client. Only Official Habitica clients can receive push notifications.", "pushDeviceAdded": "Push device added successfully", "pushDeviceAlreadyAdded": "The user already has the push device", + "pushDeviceNotFound": "The user has no push device with this id.", + "pushDeviceRemoved": "Push device removed successfully.", "add": "Add", "buyGemsGoldCap": "Cap raised to <%= amount %>", "mysticHourglass": "<%= amount %> Mystic Hourglass", @@ -168,5 +174,6 @@ "amazonPayments": "Amazon Payments", "timezone": "Time Zone", "timezoneUTC": "Habitica uses the time zone set on your PC, which is: <%= utc %>", - "timezoneInfo": "If that time zone is wrong, first reload this page using your browser's reload or refresh button to ensure that Habitica has the most recent information. If it is still wrong, adjust the time zone on your PC and then reload this page again.

If you use Habitica on other PCs or mobile devices, the time zone must be the same on them all. If your Dailies have been resetting at the wrong time, repeat this check on all other PCs and on a browser on your mobile devices." + "timezoneInfo": "If that time zone is wrong, first reload this page using your browser's reload or refresh button to ensure that Habitica has the most recent information. If it is still wrong, adjust the time zone on your PC and then reload this page again.

If you use Habitica on other PCs or mobile devices, the time zone must be the same on them all. If your Dailies have been resetting at the wrong time, repeat this check on all other PCs and on a browser on your mobile devices.", + "push": "Push" } diff --git a/common/script/index.js b/common/script/index.js index 5fd66a10a9..112c06a597 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -151,7 +151,6 @@ import blockUser from './ops/blockUser'; import clearPMs from './ops/clearPMs'; import deletePM from './ops/deletePM'; import reroll from './ops/reroll'; -import addPushDevice from './ops/addPushDevice'; import reset from './ops/reset'; import markPmsRead from './ops/markPMSRead'; @@ -191,7 +190,6 @@ api.ops = { clearPMs, deletePM, reroll, - addPushDevice, reset, markPmsRead, }; @@ -272,7 +270,6 @@ api.wrap = function wrapUser (user, main = true) { addWebhook: _.partial(importedOps.addWebhook, user), updateWebhook: _.partial(importedOps.updateWebhook, user), deleteWebhook: _.partial(importedOps.deleteWebhook, user), - addPushDevice: _.partial(importedOps.addPushDevice, user), clearPMs: _.partial(importedOps.clearPMs, user), deletePM: _.partial(importedOps.deletePM, user), blockUser: _.partial(importedOps.blockUser, user), diff --git a/common/script/ops/addPushDevice.js b/common/script/ops/addPushDevice.js deleted file mode 100644 index a909fe9feb..0000000000 --- a/common/script/ops/addPushDevice.js +++ /dev/null @@ -1,41 +0,0 @@ -import _ from 'lodash'; -import i18n from '../i18n'; -import { - BadRequest, - NotAuthorized, -} from '../libs/errors'; - -// TODO move to server code -module.exports = function addPushDevice (user, req = {}) { - let regId = _.get(req, 'body.regId'); - if (!regId) throw new BadRequest(i18n.t('regIdRequired', req.language)); - - let type = _.get(req, 'body.type'); - if (!type) throw new BadRequest(i18n.t('typeRequired', req.language)); - - if (!user.pushDevices) { - user.pushDevices = []; - } - - let pushDevices = user.pushDevices; - - let item = { - regId, - type, - }; - - let indexOfPushDevice = _.findIndex(pushDevices, { - regId: item.regId, - }); - - if (indexOfPushDevice !== -1) { - throw new NotAuthorized(i18n.t('pushDeviceAlreadyAdded', req.language)); - } - - pushDevices.push(item); - - return [ - user.pushDevices, - i18n.t('pushDeviceAdded', req.language), - ]; -}; diff --git a/common/script/ops/index.js b/common/script/ops/index.js index 1ed5ea72f2..606a3599c9 100644 --- a/common/script/ops/index.js +++ b/common/script/ops/index.js @@ -18,7 +18,6 @@ import deleteTag from './deleteTag'; import addWebhook from './addWebhook'; import updateWebhook from './updateWebhook'; import deleteWebhook from './deleteWebhook'; -import addPushDevice from './addPushDevice'; import clearPMs from './clearPMs'; import deletePM from './deletePM'; import blockUser from './blockUser'; @@ -68,7 +67,6 @@ module.exports = { addWebhook, updateWebhook, deleteWebhook, - addPushDevice, clearPMs, deletePM, blockUser, diff --git a/config.json.example b/config.json.example index c7f801326c..c7551fff04 100644 --- a/config.json.example +++ b/config.json.example @@ -65,10 +65,7 @@ "LOGGLY_ACCOUNT": "account", "PUSH_CONFIGS": { "GCM_SERVER_API_KEY": "", - "APN_PEM_FILES": { - "KEY": "key.pem", - "CERT": "cert.pem" - } + "APN_ENABLED": "true" }, "FIREBASE": { "APP": "app-name", diff --git a/package.json b/package.json index c567ff911e..f3e063d14f 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "paypal-rest-sdk": "^1.2.1", "pretty-data": "^0.40.0", "ps-tree": "^1.0.0", - "push-notify": "^1.1.1", + "push-notify": "habitrpg/push-notify#v1.2.0", "request": "~2.72.0", "rimraf": "^2.4.3", "run-sequence": "^1.1.4", diff --git a/test/api/v3/integration/user/DELETE-user_push_device.test.js b/test/api/v3/integration/user/DELETE-user_push_device.test.js new file mode 100644 index 0000000000..129df28d87 --- /dev/null +++ b/test/api/v3/integration/user/DELETE-user_push_device.test.js @@ -0,0 +1,32 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('DELETE /user/push-devices', () => { + let user; + let regId = '10'; + let type = 'ios'; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns an error if user does not have the push device', async () => { + await expect(user.del(`/user/push-devices/${regId}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('pushDeviceNotFound'), + }); + }); + + it('removes a push device from the user', async () => { + await user.post('/user/push-devices', {type, regId}); + let response = await user.del(`/user/push-devices/${regId}`); + await user.sync(); + + expect(response.message).to.equal(t('pushDeviceRemoved')); + expect(user.pushDevices.length).to.equal(0); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_addPushDevice.test.js b/test/api/v3/integration/user/POST-user_addPushDevice.test.js deleted file mode 100644 index 1a3a5d4f03..0000000000 --- a/test/api/v3/integration/user/POST-user_addPushDevice.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/addPushDevice', () => { - let user; - let regId = '10'; - let type = 'someRandomType'; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('returns an error if user already has the push device', async () => { - await user.post('/user/addPushDevice', {type, regId}); - await expect(user.post('/user/addPushDevice', {type, regId})) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('pushDeviceAlreadyAdded'), - }); - }); - - // More tests in common code unit tests - - it('adds a push device to the user', async () => { - let response = await user.post('/user/addPushDevice', {type, regId}); - await user.sync(); - - expect(response.message).to.equal(t('pushDeviceAdded')); - expect(user.pushDevices[0].type).to.equal(type); - expect(user.pushDevices[0].regId).to.equal(regId); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_push_device.test.js b/test/api/v3/integration/user/POST-user_push_device.test.js new file mode 100644 index 0000000000..b0e9c1dc14 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_push_device.test.js @@ -0,0 +1,60 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/push-devices', () => { + let user; + let regId = '10'; + let type = 'ios'; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns an error when regId is not provided', async () => { + await expect(user.post('/user/push-devices'), {type}) + .to.eventually.be.rejected.and.to.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('returns an error when type is not provided', async () => { + await expect(user.post('/user/push-devices', {regId})) + .to.eventually.be.rejected.and.to.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('returns an error when type is not valid', async () => { + await expect(user.post('/user/push-devices', {regId, type: 'invalid'})) + .to.eventually.be.rejected.and.to.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('returns an error if user already has the push device', async () => { + await user.post('/user/push-devices', {type, regId}); + await expect(user.post('/user/push-devices', {type, regId})) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('pushDeviceAlreadyAdded'), + }); + }); + + it('adds a push device to the user', async () => { + let response = await user.post('/user/push-devices', {type, regId}); + await user.sync(); + + expect(response.message).to.equal(t('pushDeviceAdded')); + expect(user.pushDevices[0].type).to.equal(type); + expect(user.pushDevices[0].regId).to.equal(regId); + }); +}); diff --git a/test/api/v3/unit/libs/pushNotifications.js b/test/api/v3/unit/libs/pushNotifications.js new file mode 100644 index 0000000000..cbc0d71bef --- /dev/null +++ b/test/api/v3/unit/libs/pushNotifications.js @@ -0,0 +1,185 @@ +import { model as User } from '../../../../../website/server/models/user'; +import requireAgain from 'require-again'; +import pushNotify from 'push-notify'; +import nconf from 'nconf'; + +describe('pushNotifications', () => { + let user; + let sendPushNotification; + let pathToPushNotifications = '../../../../../website/server/libs/api-v3/pushNotifications'; + let gcmSendSpy; + let apnSendSpy; + + let identifier = 'identifier'; + let title = 'title'; + let message = 'message'; + + beforeEach(() => { + user = new User(); + gcmSendSpy = sinon.spy(); + apnSendSpy = sinon.spy(); + + sandbox.stub(nconf, 'get').returns('true'); + + sandbox.stub(pushNotify, 'gcm').returns({ + on: () => null, + send: gcmSendSpy, + }); + + sandbox.stub(pushNotify, 'apn').returns({ + on: () => null, + send: apnSendSpy, + }); + + sendPushNotification = requireAgain(pathToPushNotifications); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('throws if user is not supplied', () => { + expect(sendPushNotification).to.throw; + expect(gcmSendSpy).to.not.have.been.called; + expect(apnSendSpy).to.not.have.been.called; + }); + + it('throws if user.preferences.pushNotifications.unsubscribeFromAll is true', () => { + user.preferences.pushNotifications.unsubscribeFromAll = true; + expect(() => sendPushNotification(user)).to.throw; + expect(gcmSendSpy).to.not.have.been.called; + expect(apnSendSpy).to.not.have.been.called; + }); + + it('throws if details.identifier is not supplied', () => { + expect(() => sendPushNotification(user, { + title, + message, + })).to.throw; + expect(gcmSendSpy).to.not.have.been.called; + expect(apnSendSpy).to.not.have.been.called; + }); + + it('throws if details.title is not supplied', () => { + expect(() => sendPushNotification(user, { + identifier, + message, + })).to.throw; + expect(gcmSendSpy).to.not.have.been.called; + expect(apnSendSpy).to.not.have.been.called; + }); + + it('throws if details.message is not supplied', () => { + expect(() => sendPushNotification(user, { + identifier, + title, + })).to.throw; + expect(gcmSendSpy).to.not.have.been.called; + expect(apnSendSpy).to.not.have.been.called; + }); + + it('returns if no device is registered', () => { + sendPushNotification(user, { + identifier, + title, + message, + }); + expect(gcmSendSpy).to.not.have.been.called; + expect(apnSendSpy).to.not.have.been.called; + }); + + it('uses GCM for Android devices', () => { + user.pushDevices.push({ + type: 'android', + regId: '123', + }); + + let details = { + identifier, + title, + message, + payload: { + a: true, + b: true, + }, + timeToLive: 23, + }; + + sendPushNotification(user, details); + expect(gcmSendSpy).to.have.been.calledOnce; + expect(gcmSendSpy).to.have.been.calledWithMatch({ + registrationId: '123', + delayWhileIdle: true, + timeToLive: 23, + data: { + identifier, + title, + message, + a: true, + b: true, + }, + }); + expect(apnSendSpy).to.not.have.been.called; + }); + + it('defaults timeToLive to 15', () => { + user.pushDevices.push({ + type: 'android', + regId: '123', + }); + + let details = { + identifier, + title, + message, + }; + + sendPushNotification(user, details); + expect(gcmSendSpy).to.have.been.calledOnce; + expect(gcmSendSpy).to.have.been.calledWithMatch({ + registrationId: '123', + delayWhileIdle: true, + timeToLive: 15, + data: { + identifier, + title, + message, + }, + }); + expect(apnSendSpy).to.not.have.been.called; + }); + + // TODO disabled because APN relies on a Promise + xit('uses APN for iOS devices', () => { + user.pushDevices.push({ + type: 'ios', + regId: '123', + }); + + let details = { + identifier, + title, + message, + category: 'fun', + payload: { + a: true, + b: true, + }, + }; + + sendPushNotification(user, details); + expect(apnSendSpy).to.have.been.calledOnce; + expect(apnSendSpy).to.have.been.calledWithMatch({ + token: '123', + alert: message, + sound: 'default', + category: 'fun', + payload: { + identifier, + a: true, + b: true, + }, + }); + expect(gcmSendSpy).to.not.have.been.called; + }); +}); diff --git a/test/common/ops/addPushDevice.js b/test/common/ops/addPushDevice.js deleted file mode 100644 index 535d288384..0000000000 --- a/test/common/ops/addPushDevice.js +++ /dev/null @@ -1,59 +0,0 @@ -import addPushDevice from '../../../common/script/ops/addPushDevice'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; -import { - NotAuthorized, - BadRequest, -} from '../../../common/script/libs/errors'; - -describe('shared.ops.addPushDevice', () => { - let user; - let regId = '10'; - let type = 'someRandomType'; - - beforeEach(() => { - user = generateUser(); - user.stats.hp = 0; - }); - - it('returns an error when regId is not provided', (done) => { - try { - addPushDevice(user); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('regIdRequired')); - done(); - } - }); - - it('returns an error when type is not provided', (done) => { - try { - addPushDevice(user, {body: {regId}}); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('typeRequired')); - done(); - } - }); - - it('adds a push device', () => { - let [, message] = addPushDevice(user, {body: {regId, type}}); - - expect(message).to.equal(i18n.t('pushDeviceAdded')); - expect(user.pushDevices[0].type).to.equal(type); - expect(user.pushDevices[0].regId).to.equal(regId); - }); - - it('does not a push device twice', (done) => { - try { - addPushDevice(user, {body: {regId, type}}); - addPushDevice(user, {body: {regId, type}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('pushDeviceAlreadyAdded')); - done(); - } - }); -}); diff --git a/website/server/controllers/api-v2/pushNotifications.js b/website/server/controllers/api-v2/pushNotifications.js index 860cfbf56a..6b73816c94 100644 --- a/website/server/controllers/api-v2/pushNotifications.js +++ b/website/server/controllers/api-v2/pushNotifications.js @@ -1,58 +1,6 @@ // TODO move to /api-v2 var api = module.exports; -var _ = require('lodash'); -var nconf = require('nconf'); - -var pushNotify = require('push-notify'); - -var gcmApiKey = nconf.get("PUSH_CONFIGS:GCM_SERVER_API_KEY"); - -var gcm = gcmApiKey ? pushNotify.gcm({ - apiKey: gcmApiKey, - retries: 3 -}) : undefined; - -if(gcm){ - gcm.on('transmitted', function (result, message, registrationId) { - //console.info("transmitted", result, message, registrationId); - }); - - gcm.on('transmissionError', function (error, message, registrationId) { - //console.info("transmissionError", error, message, registrationId); - }); - gcm.on('updated', function (result, registrationId) { - //console.info("updated", result, registrationId); - }); -} api.sendNotify = function(user, title, msg, timeToLive){ - timeToLive = timeToLive || 15; - - // need investigation: - // https://github.com/HabitRPG/habitrpg/issues/5252 - if(!user) - return; - - _.forEach(user.pushDevices, function(pushDevice){ - switch(pushDevice.type){ - case "android": - if(gcm){ - gcm.send({ - registrationId: pushDevice.regId, - //collapseKey: 'COLLAPSE_KEY', - delayWhileIdle: true, - timeToLive: timeToLive, - data: { - title: title, - message: msg - } - }); - } - - break; - - case "ios": - break; - } - }); + // Disabled, see libs/api-v3/pushNotifications }; diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index 84bb256ddb..3f332f3b7c 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -20,7 +20,6 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; -import common from '../../../../common'; import sendPushNotification from '../../libs/api-v3/pushNotifications'; let api = {}; @@ -537,11 +536,18 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { sendTxnEmail(userToInvite, `invited-${groupTemplate}`, emailVars); } - sendPushNotification( - userToInvite, - common.i18n.t(group.type === 'guild' ? 'invitedGuild' : 'invitedParty'), - group.name - ); + if (userToInvite.preferences.pushNotifications[`invited${groupLabel}`] !== false) { + let identifier = group.type === 'guild' ? 'invitedGuild' : 'invitedParty'; + sendPushNotification( + userToInvite, + { + title: group.name, + message: res.t(identifier), + identifier, + payload: {groupID: group._id}, + } + ); + } let userInvited = await userToInvite.save(); if (group.type === 'guild') { diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js index 5a4aaff825..f539dbc469 100644 --- a/website/server/controllers/api-v3/members.js +++ b/website/server/controllers/api-v3/members.js @@ -302,6 +302,18 @@ api.sendPrivateMessage = { {name: 'PMS_INBOX_URL', content: '/#/options/groups/inbox'}, ]); } + if (receiver.preferences.pushNotifications.newPM !== false) { + sendPushNotification( + receiver, + { + title: res.t('newPM'), + message: res.t('newPMInfo', {name: getUserInfo(sender, ['name']).name, message}), + identifier: 'newPM', + category: 'newPM', + payload: {replyTo: sender._id}, + } + ); + } res.respond(200, {}); }, @@ -372,8 +384,15 @@ api.transferGems = { {name: 'X_GEMS_GIFTED', content: gemAmount}, ]); } - - sendPushNotification(sender, res.t('giftedGems'), res.t('giftedGemsInfo', { amount: gemAmount, name: byUsername })); + if (receiver.preferences.pushNotifications.giftedGems !== false) { + sendPushNotification(receiver, + { + title: res.t('giftedGems'), + message: res.t('giftedGemsInfo', {amount: gemAmount, name: byUsername}), + identifier: 'giftedGems', + payload: {replyTo: sender._id}, + }); + } res.respond(200, {}); }, diff --git a/website/server/controllers/api-v3/pushNotifications.js b/website/server/controllers/api-v3/pushNotifications.js new file mode 100644 index 0000000000..49ff1abd3c --- /dev/null +++ b/website/server/controllers/api-v3/pushNotifications.js @@ -0,0 +1,95 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import { + NotAuthorized, + NotFound, +} from '../../libs/api-v3/errors'; + +let api = {}; + +/** + * @apiIgnore + * @api {post} /api/v3/user/push-devices Add a push device to a user + * @apiVersion 3.0.0 + * @apiName UserAddPushDevice + * @apiGroup User + * + * @apiParam {string} regId The id of the push device + * @apiParam {string} type The type of push device + * + * @apiSuccess {Object} data List of push devices + * @apiSuccess {string} message Success message + */ +api.addPushDevice = { + method: 'POST', + url: '/user/push-devices', + middlewares: [authWithHeaders()], + async handler (req, res) { + let user = res.locals.user; + + req.checkBody('regId', res.t('regIdRequired')).notEmpty(); + req.checkBody('type', res.t('typeRequired')).notEmpty().isIn(['ios', 'android']); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let pushDevices = user.pushDevices; + + let item = { + regId: req.body.regId, + type: req.body.type, + }; + + if (pushDevices.find(device => device.regId === item.regId)) { + throw new NotAuthorized(res.t('pushDeviceAlreadyAdded')); + } + + pushDevices.push(item); + + await user.save(); + + res.respond(200, user.pushDevices, res.t('pushDeviceAdded')); + }, +}; + +/** + * @apiIgnore + * @api {delete} /api/v3/user/push-devices remove a push device from a user + * @apiVersion 3.0.0 + * @apiName UserRemovePushDevice + * @apiGroup User + * + * @apiParam {string} regId The id of the push device + * + * @apiSuccess {Object} data List of push devices + * @apiSuccess {string} message Success message + */ +api.removePushDevice = { + method: 'DELETE', + url: '/user/push-devices/:regId', + middlewares: [authWithHeaders()], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('regId', res.t('regIdRequired')).notEmpty(); + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + let regId = req.params.regId; + + let pushDevices = user.pushDevices; + + let indexOfPushDevice = pushDevices.findIndex((element) => { + return element.regId === regId; + }); + + if (indexOfPushDevice === -1) { + throw new NotFound(res.t('pushDeviceNotFound')); + } + + pushDevices.splice(indexOfPushDevice, 1); + await user.save(); + + res.respond(200, user.pushDevices, res.t('pushDeviceRemoved')); + }, +}; + +module.exports = api; diff --git a/website/server/controllers/api-v3/quests.js b/website/server/controllers/api-v3/quests.js index cf9409e86d..e5afa27abb 100644 --- a/website/server/controllers/api-v3/quests.js +++ b/website/server/controllers/api-v3/quests.js @@ -106,11 +106,18 @@ api.inviteToQuest = { let inviterVars = getUserInfo(user, ['name', 'email']); let membersToEmail = members.filter(member => { // send push notifications while filtering members before sending emails - sendPushNotification( - member, - common.i18n.t('questInvitationTitle'), - common.i18n.t('questInvitationInfo', { quest: quest.text() }) - ); + if (member.preferences.pushNotifications.invitedQuest !== false) { + sendPushNotification( + member, + { + title: res.t('questInvitationTitle'), + message: res.t('questInvitationInfo', {quest: quest.text(req.language)}), + identifier: 'questInvitation', + category: 'questInvitation', + } + + ); + } return member.preferences.emailNotifications.invitedQuest !== false; }); diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 34a64f4975..919b2fde71 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -1266,32 +1266,6 @@ api.userReroll = { }, }; -/** -* @api {post} /api/v3/user/addPushDevice Add a push device to a user -* @apiVersion 3.0.0 -* @apiName UserAddPushDevice -* @apiGroup User -* -* @apiParam {string} regId The id of the push device -* @apiParam {string} uuid The type of push device -* -* @apiSuccess {Object} data List of push devices -* @apiSuccess {string} message Success message -*/ -api.userAddPushDevice = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/addPushDevice', - async handler (req, res) { - let user = res.locals.user; - - let addPushDeviceRes = common.ops.addPushDevice(user, req); - await user.save(); - - res.respond(200, ...addPushDeviceRes); - }, -}; - /** * @api {post} /api/v3/user/reset Reset user * @apiVersion 3.0.0 diff --git a/website/server/controllers/top-level/dataexport.js b/website/server/controllers/top-level/dataexport.js index 9042547a7a..a098121f06 100644 --- a/website/server/controllers/top-level/dataexport.js +++ b/website/server/controllers/top-level/dataexport.js @@ -9,16 +9,14 @@ import csvStringify from '../../libs/api-v3/csvStringify'; import moment from 'moment'; import js2xml from 'js2xmlparser'; import Pageres from 'pageres'; -import AWS from 'aws-sdk'; import nconf from 'nconf'; import got from 'got'; import Bluebird from 'bluebird'; import locals from '../../middlewares/api-v3/locals'; +import { + S3, +} from '../../libs/api-v3/aws'; -let S3 = new AWS.S3({ - accessKeyId: nconf.get('S3:accessKeyId'), - secretAccessKey: nconf.get('S3:secretAccessKey'), -}); const S3_BUCKET = nconf.get('S3:bucket'); const BASE_URL = nconf.get('BASE_URL'); diff --git a/website/server/libs/api-v3/aws.js b/website/server/libs/api-v3/aws.js new file mode 100644 index 0000000000..82b151c81f --- /dev/null +++ b/website/server/libs/api-v3/aws.js @@ -0,0 +1,7 @@ +import AWS from 'aws-sdk'; +import nconf from 'nconf'; + +export const S3 = new AWS.S3({ + accessKeyId: nconf.get('S3:accessKeyId'), + secretAccessKey: nconf.get('S3:secretAccessKey'), +}); diff --git a/website/server/libs/api-v3/payments.js b/website/server/libs/api-v3/payments.js index ae825abff3..ea1a979a30 100644 --- a/website/server/libs/api-v3/payments.js +++ b/website/server/libs/api-v3/payments.js @@ -105,7 +105,16 @@ api.createSubscription = async function createSubscription (data) { } if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself - sendPushNotification(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`); + if (data.gift.member.preferences.pushNotifications.giftedSubscription !== false) { + sendPushNotification(data.gift.member, + { + title: shared.i18n.t('giftedSubscription'), + message: shared.i18n.t('giftedSubscriptionInfo', {months, name: byUserName}), + identifier: 'giftedSubscription', + payload: {replyTo: data.user._id}, + } + ); + } } } @@ -178,7 +187,16 @@ api.buyGems = async function buyGems (data) { } if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself - sendPushNotification(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`); + if (data.gift.member.preferences.pushNotifications.giftedGems !== false) { + sendPushNotification( + data.gift.member, + { + title: shared.i18n.t('giftedGems'), + message: shared.i18n.t('giftedGemsInfo', {amount: gemAmount, name: byUsername}), + identifier: 'giftedGems', + } + ); + } } await data.gift.member.save(); diff --git a/website/server/libs/api-v3/pushNotifications.js b/website/server/libs/api-v3/pushNotifications.js index ce5af61661..7fe821f53d 100644 --- a/website/server/libs/api-v3/pushNotifications.js +++ b/website/server/libs/api-v3/pushNotifications.js @@ -1,8 +1,12 @@ -/* eslint-disable */ - import _ from 'lodash'; import nconf from 'nconf'; import pushNotify from 'push-notify'; +import apnLib from 'apn'; +import logger from './logger'; +import Bluebird from 'bluebird'; +import { + S3, +} from './aws'; const GCM_API_KEY = nconf.get('PUSH_CONFIGS:GCM_SERVER_API_KEY'); @@ -11,47 +15,95 @@ let gcm = GCM_API_KEY ? pushNotify.gcm({ retries: 3, }) : undefined; -// TODO review and test this file when push notifications are added back - if (gcm) { - gcm.on('transmitted', (/* result, message, registrationId */) => { - // console.info("transmitted", result, message, registrationId); - }); - - gcm.on('transmissionError', (/* error, message, registrationId */) => { - // console.info("transmissionError", error, message, registrationId); - }); - - gcm.on('updated', (/* result, registrationId */) => { - // console.info("updated", result, registrationId); + gcm.on('transmissionError', (err, message, registrationId) => { + logger.error('GCM Error', err, message, registrationId); }); } -module.exports = function sendNotification (user, title, message, timeToLive = 15) { - return; // TODO push notifications are not currently enabled +let apn; - if (!user) return; +// Load APN certificate and key from S3 +const APN_ENABLED = nconf.get('PUSH_CONFIGS:APN_ENABLED') === 'true'; +const S3_BUCKET = nconf.get('S3:bucket'); + +if (APN_ENABLED) { + Bluebird.all([ + S3.getObject({ + Bucket: S3_BUCKET, + Key: 'apple_apn/cert.pem', + }).promise(), + S3.getObject({ + Bucket: S3_BUCKET, + Key: 'apple_apn/key.pem', + }).promise(), + ]) + .then(([certObj, keyObj]) => { + let cert = certObj.Body.toString(); + let key = keyObj.Body.toString(); + + apn = pushNotify.apn({ + key, + cert, + }); + + apn.on('error', err => logger.error('APN error', err)); + apn.on('transmissionError', (errorCode, notification, device) => { + logger.error('APN transmissionError', errorCode, notification, device); + }); + + let feedback = new apnLib.Feedback({ + key, + cert, + batchFeedback: true, + interval: 3600, // Check for feedback once an hour + }); + + feedback.on('feedback', (devices) => { + if (devices && devices.length > 0) { + logger.info('Delivery of push notifications failed for some Apple devices.', devices); + } + }); + }); +} + +module.exports = function sendNotification (user, details = {}) { + if (!user) throw new Error('User is required.'); + if (user.preferences.pushNotifications.unsubscribeFromAll === true) return; let pushDevices = user.pushDevices.toObject ? user.pushDevices.toObject() : user.pushDevices; + if (!details.identifier) throw new Error('details.identifier is required.'); + if (!details.title) throw new Error('details.title is required.'); + if (!details.message) throw new Error('details.message is required.'); + + let payload = details.payload ? details.payload : {}; + payload.identifier = details.identifier; + _.each(pushDevices, pushDevice => { switch (pushDevice.type) { case 'android': if (gcm) { + payload.title = details.title; + payload.message = details.message; gcm.send({ registrationId: pushDevice.regId, - // collapseKey: 'COLLAPSE_KEY', delayWhileIdle: true, - timeToLive, - data: { - title, - message, - }, + timeToLive: details.timeToLive ? details.timeToLive : 15, + data: payload, }); } - break; case 'ios': + if (apn) { + apn.send({ + token: pushDevice.regId, + alert: details.message, + sound: 'default', + category: details.category, + payload, + }); + } break; } }); diff --git a/website/server/models/challenge.js b/website/server/models/challenge.js index b9f7e87e6a..9f6fded6f7 100644 --- a/website/server/models/challenge.js +++ b/website/server/models/challenge.js @@ -296,8 +296,14 @@ schema.methods.closeChal = async function closeChal (broken = {}) { {name: 'CHALLENGE_NAME', content: challenge.name}, ]); } - - sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); + if (savedWinner.preferences.pushNotifications.wonChallenge !== false) { + sendPushNotification(savedWinner, + { + title: challenge.name, + message: shared.i18n.t('wonChallenge'), + identifier: 'wonChallenge', + }); + } } // Run some operations in the background withouth blocking the thread diff --git a/website/server/models/group.js b/website/server/models/group.js index 7fbeb8abe4..c7c4ace84e 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -405,18 +405,29 @@ schema.methods.startQuest = async function startQuest (user) { // send notifications in the background without blocking User.find( { _id: { $in: nonUserQuestMembers } }, - 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications pushDevices profile.name' + 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications preferences.pushNotifications pushDevices profile.name' ).exec().then((membersToNotify) => { let membersToEmail = _.filter(membersToNotify, (member) => { // send push notifications and filter users that disabled emails - sendPushNotification(member, 'HabitRPG', `${shared.i18n.t('questStarted')}: ${quest.text()}`); - return member.preferences.emailNotifications.questStarted !== false && member._id !== user._id; }); sendTxnEmail(membersToEmail, 'quest-started', [ { name: 'PARTY_URL', content: '/#/options/groups/party' }, ]); + let membersToPush = _.filter(membersToNotify, (member) => { + // send push notifications and filter users that disabled emails + return member.preferences.pushNotifications.questStarted !== false && + member._id !== user._id; + }); + _.each(membersToPush, (member) => { + sendPushNotification(member, + { + title: quest.text(), + message: `${shared.i18n.t('questStarted')}: ${quest.text()}`, + identifier: 'questStarted', + }); + }); }); }; diff --git a/website/server/models/pushDevice.js b/website/server/models/pushDevice.js new file mode 100644 index 0000000000..bab33983e5 --- /dev/null +++ b/website/server/models/pushDevice.js @@ -0,0 +1,21 @@ +import mongoose from 'mongoose'; +import baseModel from '../libs/api-v3/baseModel'; + +const Schema = mongoose.Schema; + +export let schema = new Schema({ + regId: {type: String, required: true}, + type: {type: String, required: true, enum: ['ios', 'android']}, +}, { + strict: true, + minimize: false, // So empty objects are returned + _id: false, +}); + +schema.plugin(baseModel, { + noSet: ['_id', 'regId'], + timestamps: true, + _id: false, +}); + +export let model = mongoose.model('PushDevice', schema); diff --git a/website/server/models/user/schema.js b/website/server/models/user/schema.js index bd37ec4bf8..803d2ed1f4 100644 --- a/website/server/models/user/schema.js +++ b/website/server/models/user/schema.js @@ -3,6 +3,7 @@ import shared from '../../../../common'; import _ from 'lodash'; import validator from 'validator'; import { schema as TagSchema } from '../tag'; +import { schema as PushDeviceSchema } from '../pushDevice'; import { schema as UserNotificationSchema, } from '../userNotification'; @@ -431,6 +432,17 @@ let schema = new Schema({ importantAnnouncements: {type: Boolean, default: true}, weeklyRecaps: {type: Boolean, default: true}, }, + pushNotifications: { + unsubscribeFromAll: {type: Boolean, default: false}, + newPM: {type: Boolean, default: true}, + wonChallenge: {type: Boolean, default: true}, + giftedGems: {type: Boolean, default: true}, + giftedSubscription: {type: Boolean, default: true}, + invitedParty: {type: Boolean, default: true}, + invitedGuild: {type: Boolean, default: true}, + questStarted: {type: Boolean, default: true}, + invitedQuest: {type: Boolean, default: true}, + }, suppressModals: { levelUp: {type: Boolean, default: false}, hatchPet: {type: Boolean, default: false}, @@ -505,10 +517,7 @@ let schema = new Schema({ extra: {type: Schema.Types.Mixed, default: () => { return {}; }}, - pushDevices: [{ - regId: {type: String}, - type: {type: String}, - }], + pushDevices: [PushDeviceSchema], }, { strict: true, minimize: false, // So empty objects are returned diff --git a/website/views/options/settings.jade b/website/views/options/settings.jade index 754dd8dcda..7cb7e98227 100644 --- a/website/views/options/settings.jade +++ b/website/views/options/settings.jade @@ -321,25 +321,46 @@ script(id='partials/options.settings.notifications.html', type="text/ng-template .personal-options.col-md-6 .panel.panel-default .panel-heading - =env.t('emailNotifications') + =env.t('notifications') .panel-body - - each notification in ['newPM', 'wonChallenge', 'giftedGems', 'giftedSubscription', 'invitedParty', 'invitedGuild', 'kickedGroup', 'questStarted', 'invitedQuest', 'importantAnnouncements', 'weeklyRecaps'] - -var preference = 'user.preferences.emailNotifications.' + notification - -var unsubscribeFromAll = 'user.preferences.emailNotifications.unsubscribeFromAll' - - .checkbox: label - input(type='checkbox', ng-model='#{preference}', - ng-disabled='#{unsubscribeFromAll} === true', - ng-checked='#{unsubscribeFromAll} === false && #{preference} === true', - ng-change='set({"preferences.emailNotifications.#{notification}": #{preference} ? true: false})') - span=env.t(notification) + table.table + tr + td + th + span=env.t("email") + th + span=env.t("push") + -var unsubscribeFromAllEmails = 'user.preferences.emailNotifications.unsubscribeFromAll' + -var unsubscribeFromAllPush = 'user.preferences.pushNotifications.unsubscribeFromAll' + each notification in ['newPM', 'wonChallenge', 'giftedGems', 'giftedSubscription', 'invitedParty', 'invitedGuild', 'kickedGroup', 'questStarted', 'invitedQuest', 'importantAnnouncements', 'weeklyRecaps'] + tr + td + span=env.t(notification) + td + -var preference = 'user.preferences.emailNotifications.' + notification + input(type='checkbox', ng-model='#{preference}', + ng-disabled='#{unsubscribeFromAllEmails} === true || #{preference} === undefined', + ng-checked='#{unsubscribeFromAllEmails} === false && #{preference} === true', + ng-change='set({"preferences.emailNotifications.#{notification}": #{preference} ? true: false})') + td + -var preference = 'user.preferences.pushNotifications.' + notification + input(type='checkbox', ng-model='#{preference}', + ng-disabled='#{unsubscribeFromAllPush} === true || #{preference} === undefined', + ng-checked='#{unsubscribeFromAllPush} === false && #{preference} === true', + ng-change='set({"preferences.pushNotifications.#{notification}": #{preference} ? true: false})') hr .checkbox label - input(type='checkbox', ng-model='user.preferences.emailNotifications.unsubscribeFromAll', ng-change='set({"preferences.emailNotifications.unsubscribeFromAll": user.preferences.emailNotifications.unsubscribeFromAll ? true: false})') + input(type='checkbox', ng-model='user.preferences.pushNotifications.unsubscribeFromAll', + ng-change='set({"preferences.pushNotifications.unsubscribeFromAll": user.preferences.pushNotifications.unsubscribeFromAll ? true: false})') + span=env.t('unsubscribeAllPush') + + .checkbox + label + input(type='checkbox', ng-model='user.preferences.emailNotifications.unsubscribeFromAll', + ng-change='set({"preferences.emailNotifications.unsubscribeFromAll": user.preferences.emailNotifications.unsubscribeFromAll ? true: false})') span=env.t('unsubscribeAllEmails') small=env.t('unsubscribeAllEmailsText')