apps/meteor/app/push/server/push.ts
import type { IAppsTokens, RequiredField, Optional, IPushNotificationConfig } from '@rocket.chat/core-typings';
import { AppsTokens } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { pick } from '@rocket.chat/tools';
import Ajv from 'ajv';
import { JWT } from 'google-auth-library';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../settings/server';
import { initAPN, sendAPN } from './apn';
import type { PushOptions, PendingPushNotification } from './definition';
import { sendFCM } from './fcm';
import { sendGCM } from './gcm';
import { logger } from './logger';
export const _matchToken = Match.OneOf({ apn: String }, { gcm: String });
const ajv = new Ajv({
coerceTypes: true,
});
export type FCMCredentials = {
type: string;
project_id: string;
private_key_id: string;
private_key: string;
client_email: string;
client_id: string;
auth_uri: string;
token_uri: string;
auth_provider_x509_cert_url: string;
client_x509_cert_url: string;
universe_domain: string;
};
export const FCMCredentialsValidationSchema = {
type: 'object',
properties: {
type: {
type: 'string',
},
project_id: {
type: 'string',
},
private_key_id: {
type: 'string',
},
private_key: {
type: 'string',
},
client_email: {
type: 'string',
},
client_id: {
type: 'string',
},
auth_uri: {
type: 'string',
},
token_uri: {
type: 'string',
},
auth_provider_x509_cert_url: {
type: 'string',
},
client_x509_cert_url: {
type: 'string',
},
universe_domain: {
type: 'string',
},
},
required: ['client_email', 'project_id', 'private_key_id', 'private_key'],
};
export const isFCMCredentials = ajv.compile<FCMCredentials>(FCMCredentialsValidationSchema);
// This type must match the type defined in the push gateway
type GatewayNotification = {
uniqueId: string;
from: string;
title: string;
text: string;
badge?: number;
sound?: string;
notId?: number;
contentAvailable?: 1 | 0;
forceStart?: number;
topic?: string;
apn?: {
from?: string;
title?: string;
text?: string;
badge?: number;
sound?: string;
notId?: number;
category?: string;
};
gcm?: {
from?: string;
title?: string;
text?: string;
image?: string;
style?: string;
summaryText?: string;
picture?: string;
badge?: number;
sound?: string;
notId?: number;
actions?: any[];
};
query?: {
userId: any;
};
token?: IAppsTokens['token'];
tokens?: IAppsTokens['token'][];
payload?: Record<string, any>;
delayUntil?: Date;
createdAt: Date;
createdBy?: string;
};
export type NativeNotificationParameters = {
userTokens: string | string[];
notification: PendingPushNotification;
_replaceToken: (currentToken: IAppsTokens['token'], newToken: IAppsTokens['token']) => void;
_removeToken: (token: IAppsTokens['token']) => void;
options: RequiredField<PushOptions, 'gcm'>;
};
class PushClass {
options: PushOptions = {
uniqueId: '',
};
isConfigured = false;
public configure(options: PushOptions): void {
this.options = {
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 as RequiredField<PushOptions, 'apn'>, absoluteUrl: Meteor.absoluteUrl() });
}
}
private replaceToken(currentToken: IAppsTokens['token'], newToken: IAppsTokens['token']): void {
void AppsTokens.updateMany({ token: currentToken }, { $set: { token: newToken } });
}
private removeToken(token: IAppsTokens['token']): void {
void AppsTokens.deleteOne({ token });
}
private shouldUseGateway(): boolean {
return Boolean(!!this.options.gateways && settings.get('Register_Server') && settings.get('Cloud_Service_Agree_PrivacyTerms'));
}
private async sendNotificationNative(
app: IAppsTokens,
notification: PendingPushNotification,
countApn: string[],
countGcm: string[],
): Promise<void> {
logger.debug('send to token', app.token);
if ('apn' in app.token && app.token.apn) {
countApn.push(app._id);
// Send to APN
if (this.options.apn) {
sendAPN({ userToken: app.token.apn, notification: { topic: app.appName, ...notification }, _removeToken: this.removeToken });
}
} else if ('gcm' in app.token && 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
// TODO: Remove this after the legacy provider is removed
const useLegacyProvider = settings.get<boolean>('Push_UseLegacy');
if (!useLegacyProvider) {
// override this.options.gcm.apiKey with the oauth2 token
const { projectId, token } = await this.getNativeNotificationAuthorizationCredentials();
const sendGCMOptions = {
...this.options,
gcm: {
...this.options.gcm,
apiKey: token,
projectNumber: projectId,
},
};
sendFCM({
userTokens: app.token.gcm,
notification,
_replaceToken: this.replaceToken,
_removeToken: this.removeToken,
options: sendGCMOptions as RequiredField<PushOptions, 'gcm'>,
});
} else if (this.options.gcm?.apiKey) {
sendGCM({
userTokens: app.token.gcm,
notification,
_replaceToken: this.replaceToken,
_removeToken: this.removeToken,
options: this.options as RequiredField<PushOptions, 'gcm'>,
});
}
} else {
throw new Error('send got a faulty query');
}
}
private async getNativeNotificationAuthorizationCredentials(): Promise<{ token: string; projectId: string }> {
const credentialsString = settings.get<string>('Push_google_api_credentials');
if (!credentialsString.trim()) {
throw new Error('Push_google_api_credentials is not set');
}
try {
const credentials = JSON.parse(credentialsString);
if (!isFCMCredentials(credentials)) {
throw new Error('Push_google_api_credentials is not in the correct format');
}
const client = new JWT({
email: credentials.client_email,
key: credentials.private_key,
keyId: credentials.private_key_id,
scopes: 'https://www.googleapis.com/auth/firebase.messaging',
});
await client.authorize();
return {
token: client.credentials.access_token as string,
projectId: credentials.project_id,
};
} catch (error) {
logger.error('Error getting FCM token', error);
throw new Error('Error getting FCM token');
}
}
private async sendGatewayPush(
gateway: string,
service: 'apn' | 'gcm',
token: string,
notification: Optional<GatewayNotification, 'uniqueId'>,
tries = 0,
): Promise<void> {
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');
setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, tries + 1), ms);
}
}
private getGatewayNotificationData(notification: PendingPushNotification): Omit<GatewayNotification, 'uniqueId'> {
// Gateway currently accepts every attribute from the PendingPushNotification type, except for the priority
// If new attributes are added to the PendingPushNotification type, they'll need to be removed here as well.
const { priority: _priority, ...notifData } = notification;
return {
...notifData,
};
}
private async sendNotificationGateway(
app: IAppsTokens,
notification: PendingPushNotification,
countApn: string[],
countGcm: string[],
): Promise<void> {
if (!this.options.gateways) {
return;
}
const gatewayNotification = this.getGatewayNotificationData(notification);
for (const gateway of this.options.gateways) {
logger.debug('send to token', app.token);
if ('apn' in app.token && app.token.apn) {
countApn.push(app._id);
return this.sendGatewayPush(gateway, 'apn', app.token.apn, { topic: app.appName, ...gatewayNotification });
}
if ('gcm' in app.token && app.token.gcm) {
countGcm.push(app._id);
return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, gatewayNotification);
}
}
}
private async sendNotification(notification: PendingPushNotification): Promise<{ apn: string[]; gcm: string[] }> {
logger.debug('Sending notification', notification);
const countApn: string[] = [];
const countGcm: string[] = [];
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 } }],
};
const appTokens = AppsTokens.find(query);
for await (const app of appTokens) {
logger.debug('send to token', app.token);
if (this.shouldUseGateway()) {
await this.sendNotificationGateway(app, notification, countApn, countGcm);
continue;
}
await 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.countApnTokens()) === 0) {
logger.debug('GUIDE: The "AppsTokens" - No APN clients have registered on the server yet...');
}
} else if (!countGcm.length) {
if ((await AppsTokens.countGcmTokens()) === 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
private _validateDocument(notification: PendingPushNotification): void {
// 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),
apn: Match.Optional({
category: Match.Optional(String),
}),
gcm: Match.Optional({
image: Match.Optional(String),
style: Match.Optional(String),
}),
userId: String,
payload: Match.Optional(Object),
createdAt: Date,
createdBy: Match.OneOf(String, null),
priority: Match.Optional(Match.Integer),
});
if (!notification.userId) {
throw new Error('No userId found');
}
}
private hasApnOptions(options: IPushNotificationConfig): options is RequiredField<IPushNotificationConfig, 'apn'> {
return Match.test(options.apn, Object);
}
private hasGcmOptions(options: IPushNotificationConfig): options is RequiredField<IPushNotificationConfig, 'gcm'> {
return Match.test(options.gcm, Object);
}
public async send(options: IPushNotificationConfig) {
const notification: PendingPushNotification = {
createdAt: new Date(),
// createdBy is no longer used, but the gateway still expects it
createdBy: '<SERVER>',
sent: false,
sending: 0,
...pick(options, 'from', 'title', 'text', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority'),
...(this.hasApnOptions(options)
? {
apn: {
...pick(options.apn, 'category'),
},
}
: {}),
...(this.hasGcmOptions(options)
? {
gcm: {
...pick(options.gcm, 'image', 'style'),
},
}
: {}),
};
// Validate the notification
this._validateDocument(notification);
try {
await this.sendNotification(notification);
} catch (error: any) {
logger.debug(`Could not send notification to user "${notification.userId}", Error: ${error.message}`);
logger.debug(error.stack);
}
}
}
export const Push = new PushClass();