snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const sandbox = sinon.createSandbox();
const mocks = {
logger: {
debug: sandbox.stub(),
warn: sandbox.stub(),
error: sandbox.stub(),
},
ApnProvider: sandbox.stub(),
};
const apnMock = {
'Provider': mocks.ApnProvider,
'Notification': sandbox.stub(),
'@noCallThru': true,
};
const { initAPN, shutdownAPN } = proxyquire.noCallThru().load('./apn', {
'@parse/node-apn': {
default: apnMock,
...apnMock,
},
'./logger': { logger: mocks.logger },
});
const baseOptions = {
apn: {
cert: 'cert-data',
key: 'key-data',
gateway: undefined as string | undefined,
},
production: false,
};
const buildOptions = (overrides: Record<string, unknown> = {}, apnOverrides: Record<string, unknown> = {}) => ({
...baseOptions,
...overrides,
apn: {
...baseOptions.apn,
...apnOverrides,
},
});
describe('initAPN', () => {
beforeEach(() => {
sandbox.resetHistory();
mocks.ApnProvider.reset();
});
describe('APN provider initialization', () => {
it('should create apn.Provider with correct options', () => {
const options = buildOptions({ production: true }, { gateway: 'gateway.push.apple.com' });
initAPN({
options,
absoluteUrl: 'https://example.com',
});
expect(mocks.ApnProvider.calledWithNew()).to.be.true;
});
it('should pass production flag from options to Provider', () => {
initAPN({
options: buildOptions({ production: true }),
absoluteUrl: 'https://example.com',
});
expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('production', true);
});
it('should pass production false when options.production is false', () => {
initAPN({
options: buildOptions({ production: false }),
absoluteUrl: 'https://example.com',
});
expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('production', false);
});
it('should pass cert and key to Provider', () => {
initAPN({
options: buildOptions({}, { cert: 'my-cert', key: 'my-key' }),
absoluteUrl: 'https://example.com',
});
const providerArgs = mocks.ApnProvider.firstCall.args[0];
expect(providerArgs).to.have.property('cert', 'my-cert');
expect(providerArgs).to.have.property('key', 'my-key');
});
it('should pass gateway to Provider when specified', () => {
initAPN({
options: buildOptions({}, { gateway: 'gateway.push.apple.com' }),
absoluteUrl: 'https://example.com',
});
expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('gateway', 'gateway.push.apple.com');
});
it('should spread all apn options to Provider', () => {
initAPN({
options: buildOptions({ production: true }, { cert: 'c', key: 'k', gateway: 'gateway.sandbox.push.apple.com' }),
absoluteUrl: 'https://example.com',
});
const providerArgs = mocks.ApnProvider.firstCall.args[0];
expect(providerArgs).to.deep.equal({
cert: 'c',
key: 'k',
gateway: 'gateway.sandbox.push.apple.com',
production: true,
});
});
it('should not throw when Provider constructor throws', () => {
mocks.ApnProvider.throws(new Error('APN init failed'));
expect(() =>
initAPN({
options: buildOptions(),
absoluteUrl: 'https://example.com',
}),
).to.not.throw();
});
});
});
describe('shutdownAPN', () => {
beforeEach(() => {
sandbox.resetHistory();
mocks.ApnProvider.reset();
});
it('should shut down the active provider', async () => {
const shutdown = sandbox.stub().resolves();
mocks.ApnProvider.returns({ shutdown });
initAPN({ options: buildOptions(), absoluteUrl: 'https://example.com' });
await shutdownAPN();
expect(shutdown.calledOnce).to.be.true;
});
it('should resolve when no provider was initialized', async () => {
await shutdownAPN();
await shutdownAPN();
});
it('should not reject when provider shutdown fails', async () => {
const shutdown = sandbox.stub().rejects(new Error('shutdown failed'));
mocks.ApnProvider.returns({ shutdown });
initAPN({ options: buildOptions(), absoluteUrl: 'https://example.com' });
await shutdownAPN();
expect(mocks.logger.error.called).to.be.true;
});
});

View File

@@ -0,0 +1,172 @@
import apn from '@parse/node-apn';
import type { RequiredField } from '@rocket.chat/core-typings';
import EJSON from 'ejson';
import type { PushOptions, PendingPushNotification } from './definition';
import { logger } from './logger';
let apnConnection: apn.Provider | undefined;
declare module '@parse/node-apn' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Notification {
setContentAvailable: (value: boolean | 1 | 0) => void;
set category(_value: string | undefined);
set body(_value: string);
set title(_value: string);
}
}
export const sendAPN = ({
userToken,
notification,
_removeToken,
}: {
userToken: string;
notification: PendingPushNotification & { topic: string };
_removeToken: (token: string) => void;
}) => {
if (!apnConnection) {
throw new Error('Apn Connection not initialized.');
}
const priority = notification.priority || notification.priority === 0 ? notification.priority : 10;
const note = new apn.Notification();
// Expires 1 hour from now, unless configured otherwise.
const expirationSeconds = notification.apn?.expirationSeconds ?? 3600;
if (notification.useVoipToken) {
note.pushType = 'voip';
}
note.expiry = Math.floor(Date.now() / 1000) + expirationSeconds;
if (notification.badge !== undefined) {
note.badge = notification.badge;
}
if (notification.sound !== undefined) {
note.sound = notification.sound;
}
if (notification.contentAvailable != null) {
note.setContentAvailable(notification.contentAvailable);
}
// adds category support for iOS8 custom actions as described here:
// https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/
// RemoteNotificationsPG/Chapters/IPhoneOSClientImp.html#//apple_ref/doc/uid/TP40008194-CH103-SW36
if (notification.apn?.category) {
note.category = notification.apn.category;
}
if (notification.text) {
note.body = notification.text;
}
if (notification.title) {
note.title = notification.title;
}
if (notification.notId != null) {
note.threadId = String(notification.notId);
}
// Allow the user to set payload data
note.payload = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {};
if (notification.from) {
note.payload.messageFrom = notification.from;
}
note.priority = priority;
note.topic = notification.topic;
note.mutableContent = true;
void apnConnection.send(note, userToken).then((response) => {
response.failed.forEach((failure) => {
logger.debug({
msg: 'Got error code for APN token',
status: failure.status,
token: userToken,
});
if (['400', '410'].includes(String(failure.status))) {
logger.debug({
msg: 'Removing APN token',
token: userToken,
});
_removeToken(userToken);
}
});
});
};
export const shutdownAPN = async () => {
const connection = apnConnection;
apnConnection = undefined;
try {
await connection?.shutdown();
} catch (err) {
logger.error({ msg: 'Error shutting down APN connection', err });
}
};
export const initAPN = ({ options, absoluteUrl }: { options: RequiredField<PushOptions, 'apn'>; absoluteUrl: string }) => {
logger.debug('APN configured');
if (options.apn.gateway) {
// We check the apn gateway i the options, we could risk shipping
// server into production while using the production configuration.
// On the other hand we could be in development but using the production
// configuration. And finally we could have configured an unknown apn
// gateway (this could change in the future - but a warning about typos
// can save hours of debugging)
//
// Warn about gateway configurations - it's more a guide
if (options.apn.gateway === 'gateway.sandbox.push.apple.com') {
// Using the development sandbox
logger.warn('WARNING: Push APN is in development mode');
} else if (options.apn.gateway === 'gateway.push.apple.com') {
// In production - but warn if we are running on localhost
if (/http:\/\/localhost/.test(absoluteUrl)) {
logger.warn('WARNING: Push APN is configured to production mode - but server is running from localhost');
}
} else {
// Warn about gateways we dont know about
logger.warn({
msg: 'WARNING: Push APN unknown gateway',
gateway: options.apn.gateway,
});
}
} else if (options.production) {
if (/http:\/\/localhost/.test(absoluteUrl)) {
logger.warn('WARNING: Push APN is configured to production mode - but server is running from localhost');
}
} else {
logger.warn('WARNING: Push APN is in development mode');
}
// Check certificate data
if (!options.apn.cert?.length) {
logger.error('ERROR: Push server could not find cert');
}
// Check key data
if (!options.apn.key?.length) {
logger.error('ERROR: Push server could not find key');
}
// Rig apn connection
try {
apnConnection = new apn.Provider({
...options.apn,
production: options.production,
});
} catch (err) {
logger.error({ msg: 'Error trying to initialize APN', err });
}
};

View File

@@ -0,0 +1,47 @@
export type PushOptions = {
sendTimeout?: number;
production?: boolean;
apn?: {
passphrase: string;
key: string;
cert: string;
gateway?: string;
};
gcm?: {
apiKey: string;
projectNumber: string;
};
gateways?: string[];
uniqueId: string;
getAuthorization?: () => Promise<string>;
};
export type PendingPushNotification = {
from?: string;
title?: string;
text?: string;
badge?: number;
sound?: string;
notId?: number;
apn?: {
category?: string;
expirationSeconds?: number;
};
gcm?: {
style?: string;
image?: string;
};
payload?: Record<string, any>;
createdAt: Date;
createdBy?: string;
userId: string;
sent?: boolean;
sending?: number;
priority?: number;
contentAvailable?: 1 | 0;
useVoipToken?: boolean;
};

View File

@@ -0,0 +1,203 @@
import { serverFetch as fetch, type ExtendedFetchOptions } from '@rocket.chat/server-fetch';
import EJSON from 'ejson';
import type { Response } from 'node-fetch';
import type { PendingPushNotification } from './definition';
import { logger } from './logger';
import type { NativeNotificationParameters } from './push';
type FCMDataField = Record<string, any>;
type FCMNotificationField = {
title?: string;
body?: string;
image?: string;
};
type FCMMessage = {
notification?: FCMNotificationField;
data?: FCMDataField;
token?: string;
to?: string;
android?: {
collapseKey?: string;
priority?: 'HIGH' | 'NORMAL';
ttl?: string;
restrictedPackageName?: string;
data?: FCMDataField;
notification?: FCMNotificationField;
fcm_options?: {
analytics_label?: string;
};
direct_boot_ok?: boolean;
};
webpush?: {
headers?: FCMDataField;
data?: FCMDataField;
notification?: FCMNotificationField;
fcm_options?: {
link?: string;
analytics_label?: string;
};
};
fcm_options?: {
analytics_label?: string;
};
};
// https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode
type FCMError = {
error: {
code: number;
message: string;
status: string;
};
};
// The 403 error code is used when the server is refusing to process a request to a specific token due to
// a SENDER_ID_MISMATCH error. This error is returned when the sender ID provided in the request does not match the sender ID
// associated with the registration token.
const SENDER_ID_MISMATCH_ERROR_CODE = 403;
const NOT_FOUND_ERROR_CODE = 404;
/**
* Send a push notification using Firebase Cloud Messaging (FCM).
* implements the Firebase Cloud Messaging HTTP v1 API, and all of its retry logic,
* see: https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode
*
* Errors:
* - For 400, 401 errors: abort, and do not retry.
* - For 404 errors: remove the token from the database.
* - For 429 errors: retry after waiting for the duration set in the retry-after header. If no retry-after header is set, default to 60 seconds.
* - For 5xx errors: retry with exponential backoff.
*/
async function fetchWithRetry(url: string, _removeToken: () => void, options: ExtendedFetchOptions, retries = 0): Promise<Response> {
const MAX_RETRIES = 5;
const response = await fetch(url, options);
if (response.ok) {
return response;
}
if (retries >= MAX_RETRIES) {
logger.error('sendFCM error: max retries reached');
return response;
}
const retryAfter = response.headers.get('retry-after');
const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : 60;
if ([SENDER_ID_MISMATCH_ERROR_CODE, NOT_FOUND_ERROR_CODE].includes(response.status)) {
_removeToken();
return response;
}
if (response.status === 429) {
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
return fetchWithRetry(url, _removeToken, options, retries + 1);
}
if (response.status >= 500 && response.status < 600) {
const backoff = Math.pow(2, retries) * 10000;
await new Promise((resolve) => setTimeout(resolve, backoff));
return fetchWithRetry(url, _removeToken, options, retries + 1);
}
const error: FCMError = await response.json();
logger.error({ msg: 'sendFCM error', err: error });
return response;
}
function getFCMMessagesFromPushData(userTokens: string[], notification: PendingPushNotification): { message: FCMMessage }[] {
// first we will get the `data` field from the notification
const data: FCMDataField = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {};
// Set image
if (notification.gcm?.image) {
data.image = notification.gcm?.image;
}
// Set extra details
if (notification.badge) {
data.msgcnt = notification.badge.toString();
}
if (notification.sound) {
data.soundname = notification.sound;
}
if (notification.notId) {
data.notId = notification.notId.toString();
}
if (notification.gcm?.style) {
data.style = notification.gcm?.style;
}
if (notification.contentAvailable) {
data['content-available'] = notification.contentAvailable.toString();
}
// then we will create the notification field
const notificationField: FCMNotificationField = {
...(notification.title && { title: notification.title }),
...(notification.text && { body: notification.text }),
};
// then we will create the message
const message: FCMMessage = {
...(Object.keys(notificationField).length && { notification: notificationField }),
data,
android: {
priority: 'HIGH',
},
};
// then we will create the message for each token
return userTokens.map((token) => ({ message: { ...message, token } }));
}
export const sendFCM = function ({ userTokens, notification, _removeToken, options }: NativeNotificationParameters): void {
const tokens = typeof userTokens === 'string' ? [userTokens] : userTokens;
if (!tokens.length) {
logger.log('sendFCM no push tokens found');
return;
}
logger.debug({ msg: 'sendFCM', tokens, notification });
const messages = getFCMMessagesFromPushData(tokens, notification);
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${options.gcm.apiKey}`,
'access_token_auth': true,
} as Record<string, any>;
if (!options.gcm.projectNumber.trim()) {
logger.error('sendFCM error: GCM project number is missing');
return;
}
const url = `https://fcm.googleapis.com/v1/projects/${options.gcm.projectNumber}/messages:send`;
for (const fcmRequest of messages) {
logger.debug({ msg: 'sendFCM message', request: fcmRequest });
const removeToken = () => {
const { token } = fcmRequest.message;
token && _removeToken(token);
};
const response = fetchWithRetry(url, removeToken, {
method: 'POST',
headers,
body: JSON.stringify(fcmRequest),
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
ignoreSsrfValidation: true,
});
response.catch((err) => {
logger.error({ msg: 'sendFCM error', err });
});
}
};

View File

@@ -0,0 +1 @@
export { Push } from './push';

View File

@@ -0,0 +1,3 @@
import { Logger } from '@rocket.chat/logger';
export const logger = new Logger('Push');

View File

@@ -0,0 +1,532 @@
import type { IPushToken, RequiredField, Optional, IPushNotificationConfig } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { PushToken } from '@rocket.chat/models';
import { ajv } from '@rocket.chat/rest-typings';
import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { pick, truncateString } from '@rocket.chat/tools';
import { JWT } from 'google-auth-library';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { initAPN, sendAPN, shutdownAPN } from './apn';
import type { PushOptions, PendingPushNotification } from './definition';
import { sendFCM } from './fcm';
import { logger } from './logger';
import { settings } from '../../../settings';
export const _matchToken = Match.OneOf({ apn: String }, { gcm: String });
const PUSH_TITLE_LIMIT = 65;
const PUSH_MESSAGE_BODY_LIMIT = 240;
const PUSH_GATEWAY_MAX_RETRIES = 5;
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;
expirationSeconds?: number;
};
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?: IPushToken['token'];
tokens?: IPushToken['token'][];
payload?: Record<string, any>;
delayUntil?: Date;
createdAt: Date;
createdBy?: string;
};
export type NativeNotificationParameters = {
userTokens: string | string[];
notification: PendingPushNotification;
_removeToken: (token: string) => 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({ msg: 'Configure', options: this.options });
if (this.options.apn) {
initAPN({ options: this.options as RequiredField<PushOptions, 'apn'>, absoluteUrl: Meteor.absoluteUrl() });
}
}
public async unconfigure(): Promise<void> {
logger.info('Push service disabled: shutting down existing connections');
this.isConfigured = false;
return shutdownAPN();
}
private removeToken(token: string): void {
void PushToken.removeOrUnsetByTokenString(token).catch((err) => {
logger.error({ msg: 'Failed to remove push token', err });
});
}
private shouldUseGateway(): boolean {
if (License.hasOfflineLicense()) {
return false;
}
return Boolean(!!this.options.gateways && settings.get('Register_Server') && settings.get('Cloud_Service_Agree_PrivacyTerms'));
}
private async sendNotificationNative(
app: IPushToken,
notification: PendingPushNotification,
countApn: string[],
countGcm: string[],
): Promise<void> {
logger.debug({ msg: 'send to token', token: app.token });
if ('apn' in app.token && app.token.apn) {
const userToken = notification.useVoipToken ? app.voipToken : app.token.apn;
const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName;
// Send to APN
if (this.options.apn && userToken) {
countApn.push(app._id);
sendAPN({ userToken, notification: { topic, ...notification }, _removeToken: this.removeToken });
}
} else if ('gcm' in app.token && app.token.gcm) {
countGcm.push(app._id);
// 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,
_removeToken: this.removeToken,
options: sendGCMOptions 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({ msg: 'Error getting FCM token', err: error });
throw new Error('Error getting FCM token');
}
}
private async sendGatewayPush(
gateway: string,
service: 'apn' | 'gcm',
token: string,
notification: Optional<GatewayNotification, 'uniqueId'>,
retryOptions: { tries: number; maxRetries: number } = { tries: 0, maxRetries: PUSH_GATEWAY_MAX_RETRIES },
): Promise<void> {
notification.uniqueId = this.options.uniqueId;
const options = {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
ignoreSsrfValidation: true,
method: 'POST',
body: {
token,
options: notification,
},
...(token && this.options.getAuthorization && { headers: { Authorization: await this.options.getAuthorization() } }),
} as ExtendedFetchOptions;
const result = await fetch(`${gateway}/push/${service}/send`, options);
const response = await result.text();
if (result.status === 406) {
logger.info({ msg: 'removing push token', token });
this.removeToken(token);
return;
}
if (result.status === 422) {
logger.info({ msg: 'gateway rejected push notification. not retrying.', response });
return;
}
if (result.status === 401) {
logger.warn({ msg: 'authorization failed when sending push to gateway. not retrying.', response });
return;
}
if (result.ok) {
return;
}
const { tries, maxRetries } = retryOptions;
logger.error({ msg: 'Error sending push to gateway', tries, err: response });
if (tries < maxRetries) {
// [1, 2, 4, 8, 16] minutes (total 31)
const ms = 60000 * Math.pow(2, tries);
logger.log({ msg: 'Retrying push to gateway', tries: tries + 1, in: ms });
setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, { tries: tries + 1, maxRetries }), ms);
}
}
private getGatewayNotificationData(notification: PendingPushNotification): Omit<GatewayNotification, 'uniqueId'> {
// Gateway currently accepts every attribute from the PendingPushNotification type, except for the priority and useVoipToken
// If new attributes are added to the PendingPushNotification type, they'll need to be removed here as well.
const { priority: _priority, useVoipToken: _useVoipToken, ...notifData } = notification;
return {
...notifData,
};
}
private async sendNotificationGateway(
app: IPushToken,
notification: PendingPushNotification,
countApn: string[],
countGcm: string[],
): Promise<void> {
if (!this.options.gateways) {
return;
}
const gatewayNotification = this.getGatewayNotificationData(notification);
const retryOptions = {
tries: 0,
maxRetries: notification.useVoipToken ? 0 : PUSH_GATEWAY_MAX_RETRIES,
};
for (const gateway of this.options.gateways) {
logger.debug({ msg: 'send to token', token: app.token });
if ('apn' in app.token && app.token.apn) {
const token = notification.useVoipToken ? app.voipToken : app.token.apn;
const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName;
if (token) {
countApn.push(app._id);
return this.sendGatewayPush(gateway, 'apn', token, { topic, ...gatewayNotification }, retryOptions);
}
}
if ('gcm' in app.token && app.token.gcm) {
countGcm.push(app._id);
return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, gatewayNotification, retryOptions);
}
}
}
private async sendNotification(
notification: PendingPushNotification,
options: { skipTokenId?: IPushToken['_id'] } = {},
): Promise<{ apn: string[]; gcm: string[] }> {
logger.debug({ msg: 'Sending notification', notification });
const countApn: string[] = [];
const countGcm: string[] = [];
if (notification.from && notification.from !== String(notification.from)) {
throw new Error('Push.send: option "from" not a string');
}
if (notification.title && notification.title !== String(notification.title)) {
throw new Error('Push.send: option "title" not a string');
}
if (notification.text && notification.text !== String(notification.text)) {
throw new Error('Push.send: option "text" not a string');
}
logger.debug({
msg: 'send message to userId',
title: notification.title,
userId: notification.userId,
});
const appTokens = options.skipTokenId
? PushToken.findTokensByUserIdExceptId(notification.userId, options.skipTokenId)
: PushToken.findAllTokensByUserId(notification.userId);
for await (const app of appTokens) {
logger.debug({ msg: 'send to token', token: app.token });
if (this.shouldUseGateway()) {
await this.sendNotificationGateway(app, notification, countApn, countGcm);
continue;
}
// The workspace is configured to send through a push gateway, but the offline
// license forbids contacting it — skip quietly instead of falling back to the
// (unconfigured) native providers.
if (this.options.gateways && License.hasOfflineLicense()) {
continue;
}
await this.sendNotificationNative(app, notification, countApn, countGcm);
}
if (settings.get('Log_Level') === '2') {
logger.debug({
msg: 'Sent message to apps',
title: notification.title,
iosApps: countApn.length,
androidApps: countGcm.length,
});
// Add some verbosity about the send result, making sure the developer
// understands what just happened.
if (!countApn.length && !countGcm.length) {
if ((await PushToken.estimatedDocumentCount()) === 0) {
logger.debug('GUIDE: The "AppsTokens" is empty - No clients have registered on the server yet...');
}
} else if (!countApn.length) {
if ((await PushToken.countApnTokens()) === 0) {
logger.debug('GUIDE: The "AppsTokens" - No APN clients have registered on the server yet...');
}
} else if (!countGcm.length) {
if ((await PushToken.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: Match.Optional(String),
title: Match.Optional(String),
text: Match.Optional(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),
expirationSeconds: Match.Optional(Match.Integer),
}),
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),
useVoipToken: Match.Optional(Boolean),
});
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,
...(options.title && { title: truncateString(options.title, PUSH_TITLE_LIMIT) }),
...(options.text && { text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT) }),
...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority', 'useVoipToken'),
...(this.hasApnOptions(options)
? {
apn: {
...pick(options.apn, 'category', 'expirationSeconds'),
},
}
: {}),
...(this.hasGcmOptions(options)
? {
gcm: {
...pick(options.gcm, 'image', 'style'),
},
}
: {}),
};
// Validate the notification
this._validateDocument(notification);
try {
await this.sendNotification(notification, { skipTokenId: options.skipTokenId });
} catch (error: any) {
logger.debug({
msg: 'Could not send notification to user',
userId: notification.userId,
err: error,
});
}
}
}
export const Push = new PushClass();