UNPKG

@owstack/wallet-service

Version:

A service for multisignature HD wallets

461 lines (399 loc) 15.7 kB
const owsCommon = require('@owstack/ows-common'); const async = require('async'); const baseConfig = require('config'); const defaultRequest = require('request'); const fs = require('fs'); const log = require('npmlog'); const Model = require('./model'); const MessageBroker = require('./messagebroker'); const Mustache = require('mustache'); const path = require('path'); const sjcl = require('sjcl'); const lodash = owsCommon.deps.lodash; const $ = require('preconditions').singleton(); log.debug = log.verbose; const PUSHNOTIFICATIONS_TYPES = { NewCopayer: { filename: 'new_copayer', }, WalletComplete: { filename: 'wallet_complete', }, NewTxProposal: { filename: 'new_tx_proposal', }, NewOutgoingTx: { filename: 'new_outgoing_tx', }, NewIncomingTx: { filename: 'new_incoming_tx', }, TxProposalFinallyRejected: { filename: 'txp_finally_rejected', }, TxConfirmation: { filename: 'tx_confirmation', notifyCreatorOnly: true, }, }; class PushNotificationsService { constructor(context, config) { // Context defines the coin network and is set by the implementing service in // order to instance this base service; e.g., btc-service. context.inject(this); // Set some frequently used contant values based on context. this.LIVENET = this.ctx.Networks.livenet; this.TESTNET = this.ctx.Networks.testnet; this.config = config || baseConfig; this.setLog(); } } PushNotificationsService.prototype.setLog = function () { if (this.config.log) { log.level = (this.config.log.disable == true ? 'silent' : this.config.log.level || 'info'); } else { log.level = 'info'; } }; PushNotificationsService.prototype.start = function (opts, cb) { const self = this; opts = opts || {}; self.request = opts.request || defaultRequest; function _readDirectories(basePath, cb) { fs.readdir(basePath, function (err, files) { if (err) { return cb(err); } async.filter(files, function (file, next) { fs.stat(path.join(basePath, file), function (err, stats) { return next(!err && stats.isDirectory()); }); }, function (dirs) { return cb(null, dirs); }); }); } const pushNotificationsOpts = self.config.pushNotificationsOpts; self.templatePath = path.normalize(`${pushNotificationsOpts.templatePath || (`${__dirname }/templates`) }/`); self.defaultLanguage = pushNotificationsOpts.defaultLanguage; self.subjectPrefix = pushNotificationsOpts.subjectPrefix || ''; self.pushServerUrl = pushNotificationsOpts.pushServerUrl; self.authorizationKeys = pushNotificationsOpts.authorizationKeys; $.checkArgument(self.defaultLanguage, 'Missing defaultLanguage attribute in configuration.'); $.checkArgument(self.authorizationKeys, 'Missing authorizationKeys attribute in configuration.'); async.parallel([ function (done) { _readDirectories(self.templatePath, function (err, res) { self.availableLanguages = res; done(err); }); }, function (done) { if (opts.storage) { self.storage = opts.storage; done(); } else if (self.config.storage) { self.storage = self.config.storage; done(); } else { self.storage = new self.ctx.Storage(self.config.storageOpts, { creator: `PushNotificationsService (${ self.LIVENET.currency })` }); self.storage.connect(done); } }, function (done) { self.messageBroker = opts.messageBroker || new MessageBroker(self.config.messageBrokerOpts); self.messageBroker.onMessage(lodash.bind(self._sendPushNotifications, self)); done(); }, ], function (err) { if (err) { log.error(err); } return cb(err); }); }; PushNotificationsService.prototype._sendPushNotifications = function (notification, cb) { const self = this; cb = cb || function () {}; if (!MessageBroker.isNotificationForMe(notification, [self.LIVENET.name, self.TESTNET.name])) { return cb(); } const notifType = PUSHNOTIFICATIONS_TYPES[notification.type]; if (!notifType) { return cb(); } log.debug(`Notification received: ${ notification.type}`); log.debug(JSON.stringify(notification)); self._checkShouldSendNotif(notification, function (err, should) { if (err) { return cb(err); } log.debug('Should send notification: ', should); if (!should) { return cb(); } self._getRecipientsList(notification, notifType, function (err, recipientsList) { if (err) { return cb(err); } async.waterfall([ function (next) { self._readAndApplyTemplates(notification, notifType, recipientsList, next); }, function (contents, next) { async.map(recipientsList, function (recipient, next) { const content = contents[recipient.language]; self.storage.fetchPushNotificationSubs(recipient.copayerId, function (err, subs) { if (err) { return next(err); } const notifications = lodash.map(subs, function (sub) { return { to: sub.token, priority: 'high', restricted_package_name: sub.packageName, notification: { title: content.plain.subject, body: content.plain.body, sound: 'default', click_action: 'FCM_PLUGIN_ACTIVITY', icon: 'fcm_push_icon', }, data: { walletId: sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(notification.walletId)), copayerId: sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(recipient.copayerId)) }, }; }); return next(err, notifications); }); }, function (err, allNotifications) { if (err) { return next(err); } return next(null, lodash.flatten(allNotifications)); }); }, function (notifications, next) { async.each(notifications, function (notification, next) { self._makeRequest(notification, function (err, response) { if (err) { log.error(err); } if (response) { log.debug('Request status: ', response.statusCode); log.debug('Request message: ', response.statusMessage); log.debug('Request body: ', response.request.body); } next(); }); }, function (err) { return next(err); } ); }, ], function (err) { if (err) { log.error('An error ocurred generating notification', err); } return cb(err); }); }); }); }; PushNotificationsService.prototype._checkShouldSendNotif = function (notification, cb) { const self = this; if (notification.type != 'NewTxProposal') { return cb(null, true); } self.storage.fetchWallet(notification.walletId, function (err, wallet) { return cb(err, wallet.m > 1); }); }; PushNotificationsService.prototype._getRecipientsList = function (notification, notificationType, cb) { const self = this; self.storage.fetchWallet(notification.walletId, function (err, wallet) { if (err) { return cb(err); } self.storage.fetchPreferences(notification.walletId, null, function (err, preferences) { if (err) { log.error(err); } if (lodash.isEmpty(preferences)) { preferences = []; } let recipientPreferences = lodash.compact(lodash.map(preferences, function (p) { if (!lodash.includes(self.availableLanguages, p.language)) { if (p.language) { log.warn(`Language for notifications "${ p.language }" not available.`); } p.language = self.defaultLanguage; } return { copayerId: p.copayerId, language: p.language, unit: p.unit, }; })); recipientPreferences = lodash.keyBy(recipientPreferences, 'copayerId'); const recipientsList = lodash.compact(lodash.map(wallet.copayers, function (copayer) { if ((copayer.id == notification.creatorId && notificationType.notifyCreatorOnly) || (copayer.id != notification.creatorId && !notificationType.notifyCreatorOnly)) { const p = recipientPreferences[copayer.id] || {}; return { copayerId: copayer.id, language: p.language || self.defaultLanguage, unit: p.unit || self.ctx.Unit().standardsName() }; } })); return cb(null, recipientsList); }); }); }; PushNotificationsService.prototype._readAndApplyTemplates = function (notification, notifType, recipientsList, cb) { const self = this; async.map(recipientsList, function (recipient, next) { async.waterfall([ function (next) { self._getDataForTemplate(notification, recipient, next); }, function (data, next) { async.map(['plain', 'html'], function (type, next) { self._loadTemplate(notifType, recipient, `.${ type}`, function (err, template) { if (err && type == 'html') { return next(); } if (err) { return next(err); } self._applyTemplate(template, data, function (err, res) { return next(err, [type, res]); }); }); }, function (err, res) { return next(err, lodash.fromPairs(lodash.compact(res))); }); }, function (result, next) { next(null, result); }, ], function (err, res) { next(err, [recipient.language, res]); }); }, function (err, res) { return cb(err, lodash.fromPairs(res)); }); }; PushNotificationsService.prototype._getDataForTemplate = function (notification, recipient, cb) { const self = this; const data = lodash.cloneDeep(notification.data); data.subjectPrefix = lodash.trim(`${self.subjectPrefix } `); if (data.amount) { try { const unit = recipient.unit; data.amount = new self.ctx.Utils().formatAmount(+data.amount, unit); } catch (ex) { return cb(new Error('Could not format amount', ex)); } } self.storage.fetchWallet(notification.walletId, function (err, wallet) { if (err) { return cb(err); } data.walletId = wallet.id; data.walletName = wallet.name; data.walletM = wallet.m; data.walletN = wallet.n; const copayer = lodash.find(wallet.copayers, { id: notification.creatorId }); if (copayer) { data.copayerId = copayer.id; data.copayerName = copayer.name; } if (notification.type == 'TxProposalFinallyRejected' && data.rejectedBy) { const rejectors = lodash.map(data.rejectedBy, function (copayerId) { return lodash.find(wallet.copayers, { id: copayerId }).name; }); data.rejectorsNames = rejectors.join(', '); } return cb(null, data); }); }; PushNotificationsService.prototype._applyTemplate = function (template, data, cb) { if (!data) { return cb(new Error('Could not apply template to empty data')); } let error; const result = lodash.mapValues(template, function (t) { try { return Mustache.render(t, data); } catch (e) { log.error('Could not apply data to template', e); error = e; } }); if (error) { return cb(error); } return cb(null, result); }; PushNotificationsService.prototype._loadTemplate = function (notifType, recipient, extension, cb) { const self = this; self._readTemplateFile(recipient.language, notifType.filename + extension, function (err, template) { if (err) { return cb(err); } return cb(null, self._compileTemplate(template, extension)); }); }; PushNotificationsService.prototype._readTemplateFile = function (language, filename, cb) { const self = this; const fullFilename = path.join(self.templatePath, language, filename); fs.readFile(fullFilename, 'utf8', function (err, template) { if (err) { return cb(new Error(`Could not read template file ${ fullFilename}`, err)); } return cb(null, template); }); }; PushNotificationsService.prototype._compileTemplate = function (template, extension) { const lines = template.split('\n'); if (extension == '.html') { lines.unshift(''); } return { subject: lines[0], body: lodash.tail(lines).join('\n'), }; }; PushNotificationsService.prototype._makeRequest = function (opts, cb) { const self = this; // If multiple keys the requests are sent to all apps. const keys = self.authorizationKeys.split(','); for (let k = 0; k < keys.length; k++) { self.request({ url: `${self.pushServerUrl }/send`, method: 'POST', json: true, headers: { 'Content-Type': 'application/json', Authorization: `key=${ keys[k]}`, }, body: opts, }, function () { if (k == keys.length) { cb(); } }); } }; module.exports = PushNotificationsService;