The communications platform that puts data protection first.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
Rocket.Chat/apps/meteor/app/push/server/push.js

352 lines
9.9 KiB

import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import _ from 'underscore';
import { AppsTokens } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { initAPN, sendAPN } from './apn';
import { sendGCM } from './gcm';
import { logger } from './logger';
import { settings } from '../../settings/server';
export const _matchToken = Match.OneOf({ apn: String }, { gcm: String });
class PushClass {
options = {};
isConfigured = false;
configure(options) {
this.options = Object.assign(
{
sendTimeout: 60000, // Timeout period for notification send
},
options,
);
// https://npmjs.org/package/apn
// After requesting the certificate from Apple, export your private key as
// a .p12 file anddownload the .cer file from the iOS Provisioning Portal.
// gateway.push.apple.com, port 2195
// gateway.sandbox.push.apple.com, port 2195
// Now, in the directory containing cert.cer and key.p12 execute the
// following commands to generate your .pem files:
// $ openssl x509 -in cert.cer -inform DER -outform PEM -out cert.pem
// $ openssl pkcs12 -in key.p12 -out key.pem -nodes
// Block multiple calls
if (this.isConfigured) {
throw new Error('Configure should not be called more than once!');
}
this.isConfigured = true;
logger.debug('Configure', this.options);
if (this.options.apn) {
initAPN({ options: this.options, absoluteUrl: Meteor.absoluteUrl() });
}
}
sendWorker(task, interval) {
logger.debug(`Send worker started, using interval: ${interval}`);
return setInterval(() => {
try {
task();
} catch (error) {
logger.debug(`Error while sending: ${error.message}`);
}
}, interval);
}
_replaceToken(currentToken, newToken) {
void AppsTokens.updateMany({ token: currentToken }, { $set: { token: newToken } });
}
_removeToken(token) {
void AppsTokens.deleteOne({ token });
}
_shouldUseGateway() {
return !!this.options.gateways && settings.get('Register_Server') && settings.get('Cloud_Service_Agree_PrivacyTerms');
}
sendNotificationNative(app, notification, countApn, countGcm) {
logger.debug('send to token', app.token);
if (app.token.apn) {
countApn.push(app._id);
// Send to APN
if (this.options.apn) {
notification.topic = app.appName;
sendAPN({ userToken: app.token.apn, notification, _removeToken: this._removeToken });
}
} else if (app.token.gcm) {
countGcm.push(app._id);
// Send to GCM
// We do support multiple here - so we should construct an array
// and send it bulk - Investigate limit count of id's
if (this.options.gcm && this.options.gcm.apiKey) {
sendGCM({
userTokens: app.token.gcm,
notification,
_replaceToken: this._replaceToken,
_removeToken: this._removeToken,
options: this.options,
});
}
} else {
throw new Error('send got a faulty query');
}
}
async sendGatewayPush(gateway, service, token, notification, tries = 0) {
notification.uniqueId = this.options.uniqueId;
const options = {
method: 'POST',
body: {
token,
options: notification,
},
...(token && this.options.getAuthorization && { headers: { Authorization: await this.options.getAuthorization() } }),
};
const result = await fetch(`${gateway}/push/${service}/send`, options);
const response = await result.text();
if (result.status === 406) {
logger.info('removing push token', token);
await AppsTokens.deleteMany({
$or: [
{
'token.apn': token,
},
{
'token.gcm': token,
},
],
});
return;
}
if (result.status === 422) {
logger.info('gateway rejected push notification. not retrying.', response);
return;
}
if (result.status === 401) {
logger.warn('Error sending push to gateway (not authorized)', response);
return;
}
if (result.ok) {
return;
}
logger.error({ msg: `Error sending push to gateway (${tries} try) ->`, err: response });
if (tries <= 4) {
// [1, 2, 4, 8, 16] minutes (total 31)
const ms = 60000 * Math.pow(2, tries);
logger.log('Trying sending push to gateway again in', ms, 'milliseconds');
return setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, tries + 1), ms);
}
}
async sendNotificationGateway(app, notification, countApn, countGcm) {
for (const gateway of this.options.gateways) {
logger.debug('send to token', app.token);
if (app.token.apn) {
countApn.push(app._id);
notification.topic = app.appName;
return this.sendGatewayPush(gateway, 'apn', app.token.apn, notification);
}
if (app.token.gcm) {
countGcm.push(app._id);
return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, notification);
}
}
}
async sendNotification(notification = { badge: 0 }) {
logger.debug('Sending notification', notification);
const countApn = [];
const countGcm = [];
if (notification.from !== String(notification.from)) {
throw new Error('Push.send: option "from" not a string');
}
if (notification.title !== String(notification.title)) {
throw new Error('Push.send: option "title" not a string');
}
if (notification.text !== String(notification.text)) {
throw new Error('Push.send: option "text" not a string');
}
logger.debug(`send message "${notification.title}" to userId`, notification.userId);
const query = {
userId: notification.userId,
$or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }],
};
await AppsTokens.find(query).forEach((app) => {
logger.debug('send to token', app.token);
if (this._shouldUseGateway()) {
return this.sendNotificationGateway(app, notification, countApn, countGcm);
}
return this.sendNotificationNative(app, notification, countApn, countGcm);
});
if (settings.get('Log_Level') === '2') {
logger.debug(`Sent message "${notification.title}" to ${countApn.length} ios apps ${countGcm.length} android apps`);
// Add some verbosity about the send result, making sure the developer
// understands what just happened.
if (!countApn.length && !countGcm.length) {
if ((await AppsTokens.col.estimatedDocumentCount()) === 0) {
logger.debug('GUIDE: The "AppsTokens" is empty - No clients have registered on the server yet...');
}
} else if (!countApn.length) {
if ((await AppsTokens.col.countDocuments({ 'token.apn': { $exists: true } })) === 0) {
logger.debug('GUIDE: The "AppsTokens" - No APN clients have registered on the server yet...');
}
} else if (!countGcm.length) {
if ((await AppsTokens.col.countDocuments({ 'token.gcm': { $exists: true } })) === 0) {
logger.debug('GUIDE: The "AppsTokens" - No GCM clients have registered on the server yet...');
}
}
}
return {
apn: countApn,
gcm: countGcm,
};
}
// This is a general function to validate that the data added to notifications
// is in the correct format. If not this function will throw errors
_validateDocument(notification) {
// Check the general notification
check(notification, {
from: String,
title: String,
text: String,
sent: Match.Optional(Boolean),
sending: Match.Optional(Match.Integer),
badge: Match.Optional(Match.Integer),
sound: Match.Optional(String),
notId: Match.Optional(Match.Integer),
contentAvailable: Match.Optional(Match.Integer),
forceStart: Match.Optional(Match.Integer),
apn: Match.Optional({
from: Match.Optional(String),
title: Match.Optional(String),
text: Match.Optional(String),
badge: Match.Optional(Match.Integer),
sound: Match.Optional(String),
notId: Match.Optional(Match.Integer),
actions: Match.Optional([Match.Any]),
category: Match.Optional(String),
}),
gcm: Match.Optional({
from: Match.Optional(String),
title: Match.Optional(String),
text: Match.Optional(String),
image: Match.Optional(String),
style: Match.Optional(String),
summaryText: Match.Optional(String),
picture: Match.Optional(String),
badge: Match.Optional(Match.Integer),
sound: Match.Optional(String),
notId: Match.Optional(Match.Integer),
}),
android_channel_id: Match.Optional(String),
userId: String,
payload: Match.Optional(Object),
delayUntil: Match.Optional(Date),
createdAt: Date,
createdBy: Match.OneOf(String, null),
});
if (!notification.userId) {
throw new Error('No userId found');
}
}
async send(options) {
// If on the client we set the user id - on the server we need an option
// set or we default to "<SERVER>" as the creator of the notification
// If current user not set see if we can set it to the logged in user
// this will only run on the client if Meteor.userId is available
const currentUser = options.createdBy || '<SERVER>';
// Rig the notification object
const notification = Object.assign(
{
createdAt: new Date(),
createdBy: currentUser,
sent: false,
sending: 0,
},
_.pick(options, 'from', 'title', 'text', 'userId'),
);
// Add extra
Object.assign(notification, _.pick(options, 'payload', 'badge', 'sound', 'notId', 'delayUntil', 'android_channel_id'));
if (Match.test(options.apn, Object)) {
notification.apn = _.pick(options.apn, 'from', 'title', 'text', 'badge', 'sound', 'notId', 'category');
}
if (Match.test(options.gcm, Object)) {
notification.gcm = _.pick(
options.gcm,
'image',
'style',
'summaryText',
'picture',
'from',
'title',
'text',
'badge',
'sound',
'notId',
'actions',
'android_channel_id',
);
}
if (options.contentAvailable != null) {
notification.contentAvailable = options.contentAvailable;
}
if (options.forceStart != null) {
notification.forceStart = options.forceStart;
}
// Validate the notification
this._validateDocument(notification);
try {
await this.sendNotification(notification);
} catch (error) {
logger.debug(`Could not send notification id: "${notification._id}", Error: ${error.message}`);
logger.debug(error.stack);
}
}
}
export const Push = new PushClass();