Add UnifiedPush push notification support

This commit is contained in:
Your Name 2025-09-17 17:28:17 -06:00
parent 2166ef72bb
commit 34fd36e784
5 changed files with 151 additions and 3 deletions

View file

@ -58,6 +58,8 @@
"PUSH_CONFIGS_APN_KEY_ID": "xxxxxxxxxx", "PUSH_CONFIGS_APN_KEY_ID": "xxxxxxxxxx",
"PUSH_CONFIGS_APN_TEAM_ID": "aaabbbcccd", "PUSH_CONFIGS_APN_TEAM_ID": "aaabbbcccd",
"PUSH_CONFIGS_FCM_SERVER_API_KEY": "aaabbbcccd", "PUSH_CONFIGS_FCM_SERVER_API_KEY": "aaabbbcccd",
"PUSH_CONFIGS_UNIFIEDPUSH_URL": "",
"PUSH_CONFIGS_UNIFIEDPUSH_AUTHORIZATION": "",
"S3_ACCESS_KEY_ID": "accessKeyId", "S3_ACCESS_KEY_ID": "accessKeyId",
"S3_BUCKET": "bucket", "S3_BUCKET": "bucket",
"S3_SECRET_ACCESS_KEY": "secretAccessKey", "S3_SECRET_ACCESS_KEY": "secretAccessKey",

View file

@ -2,6 +2,7 @@ import apn from '@parse/node-apn';
import _ from 'lodash'; import _ from 'lodash';
import nconf from 'nconf'; import nconf from 'nconf';
import admin from 'firebase-admin'; import admin from 'firebase-admin';
import got from 'got';
import { model as User } from '../../../../website/server/models/user'; import { model as User } from '../../../../website/server/models/user';
import { import {
MAX_MESSAGE_LENGTH, MAX_MESSAGE_LENGTH,
@ -15,6 +16,7 @@ describe('pushNotifications', () => {
let apnSendSpy; let apnSendSpy;
let updateStub; let updateStub;
let classStubbedInstance; let classStubbedInstance;
let gotPostStub;
const identifier = 'identifier'; const identifier = 'identifier';
const title = 'title'; const title = 'title';
@ -24,8 +26,10 @@ describe('pushNotifications', () => {
user = new User(); user = new User();
fcmSendSpy = sinon.stub().returns(Promise.resolve('success')); fcmSendSpy = sinon.stub().returns(Promise.resolve('success'));
apnSendSpy = sinon.stub().returns(Promise.resolve()); apnSendSpy = sinon.stub().returns(Promise.resolve());
gotPostStub = sandbox.stub(got, 'post').resolves();
nconf.set('PUSH_CONFIGS_APN_ENABLED', 'true'); nconf.set('PUSH_CONFIGS_APN_ENABLED', 'true');
nconf.set('PUSH_CONFIGS_UNIFIEDPUSH_URL', 'https://push.example.com/');
classStubbedInstance = sandbox.createStubInstance(apn.Provider, { classStubbedInstance = sandbox.createStubInstance(apn.Provider, {
send: apnSendSpy, send: apnSendSpy,
@ -197,6 +201,7 @@ describe('pushNotifications', () => {
expect(apnSendSpy).to.have.been.calledOnce; expect(apnSendSpy).to.have.been.calledOnce;
expect(apnSendSpy).to.have.been.calledWithMatch(expectedNotification, '123'); expect(apnSendSpy).to.have.been.calledWithMatch(expectedNotification, '123');
expect(fcmSendSpy).to.not.have.been.called; expect(fcmSendSpy).to.not.have.been.called;
expect(gotPostStub).to.not.have.been.called;
}); });
it('uses FCM for Android devices', async () => { it('uses FCM for Android devices', async () => {
@ -221,6 +226,49 @@ describe('pushNotifications', () => {
expect(fcmSendSpy).to.have.been.calledOnce; expect(fcmSendSpy).to.have.been.calledOnce;
expect(fcmSendSpy).to.have.been.calledWithMatch(expectedMessage); expect(fcmSendSpy).to.have.been.calledWithMatch(expectedMessage);
expect(apnSendSpy).to.not.have.been.called; expect(apnSendSpy).to.not.have.been.called;
expect(gotPostStub).to.not.have.been.called;
});
it('uses UnifiedPush for unified push devices with base url', async () => {
user.pushDevices.push({
type: 'unifiedpush',
regId: 'abc123',
});
await sendPushNotification(user, details);
expect(gotPostStub).to.have.been.calledOnce;
expect(gotPostStub).to.have.been.calledWithMatch('https://push.example.com/abc123', {
json: {
title,
message,
identifier,
payload: {
identifier,
a: true,
b: true,
},
},
});
expect(fcmSendSpy).to.not.have.been.called;
expect(apnSendSpy).to.not.have.been.called;
});
it('uses UnifiedPush for devices with absolute endpoints', async () => {
user.pushDevices.push({
type: 'unifiedpush',
regId: 'https://custom.endpoint/token',
});
await sendPushNotification(user, details);
expect(gotPostStub).to.have.been.calledOnce;
expect(gotPostStub).to.have.been.calledWithMatch(
'https://custom.endpoint/token',
sinon.match.object,
);
expect(fcmSendSpy).to.not.have.been.called;
expect(apnSendSpy).to.not.have.been.called;
}); });
it('handles multiple devices', async () => { it('handles multiple devices', async () => {
@ -236,13 +284,17 @@ describe('pushNotifications', () => {
type: 'android', type: 'android',
regId: '789', regId: '789',
}); });
user.pushDevices.push({
type: 'unifiedpush',
regId: 'unified',
});
await sendPushNotification(user, details); await sendPushNotification(user, details);
expect(fcmSendSpy).to.have.been.calledTwice; expect(fcmSendSpy).to.have.been.calledTwice;
expect(apnSendSpy).to.have.been.calledOnce; expect(apnSendSpy).to.have.been.calledOnce;
expect(gotPostStub).to.have.been.calledOnce;
}); });
}); });
describe('handles sending errors', () => { describe('handles sending errors', () => {
let clock; let clock;
@ -324,6 +376,29 @@ describe('pushNotifications', () => {
expect(updateStub).to.have.been.calledOnce; expect(updateStub).to.have.been.calledOnce;
}); });
it('removes invalid unified push devices when endpoint gone', async () => {
user.pushDevices.push({
type: 'unifiedpush',
regId: 'unified',
});
const error = new Error('gone');
error.response = { statusCode: 410 };
gotPostStub.rejects(error);
await sendPushNotification(user, {
identifier,
title,
message,
});
expect(gotPostStub).to.have.been.calledOnce;
expect(fcmSendSpy).to.not.have.been.called;
expect(apnSendSpy).to.not.have.been.called;
await clock.tick(10);
expect(updateStub).to.have.been.calledOnce;
});
it('removes invalid apn devices', async () => { it('removes invalid apn devices', async () => {
user.pushDevices.push({ user.pushDevices.push({
type: 'ios', type: 'ios',
@ -350,5 +425,6 @@ describe('pushNotifications', () => {
expect(apnSendSpy).to.have.been.calledOnce; expect(apnSendSpy).to.have.been.calledOnce;
expect(updateStub).to.have.been.calledOnce; expect(updateStub).to.have.been.calledOnce;
}); });
}); });
}); });

View file

@ -26,7 +26,7 @@ api.addPushDevice = {
const { user } = res.locals; const { user } = res.locals;
req.checkBody('regId', res.t('regIdRequired')).notEmpty(); req.checkBody('regId', res.t('regIdRequired')).notEmpty();
req.checkBody('type', res.t('typeRequired')).notEmpty().isIn(['ios', 'android']); req.checkBody('type', res.t('typeRequired')).notEmpty().isIn(['ios', 'android', 'unifiedpush']);
const validationErrors = req.validationErrors(); const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors; if (validationErrors) throw validationErrors;

View file

@ -2,6 +2,7 @@ import _ from 'lodash';
import nconf from 'nconf'; import nconf from 'nconf';
import apn from '@parse/node-apn'; import apn from '@parse/node-apn';
import admin from 'firebase-admin'; import admin from 'firebase-admin';
import got from 'got';
import logger from './logger'; import logger from './logger';
import { // eslint-disable-line import/no-cycle import { // eslint-disable-line import/no-cycle
model as User, model as User,
@ -116,6 +117,72 @@ async function sendAPNNotification (user, pushDevice, details, payload) {
} }
} }
async function sendUnifiedPushNotification (user, pushDevice, details, payload) {
const unifiedPushBaseUrl = nconf.get('PUSH_CONFIGS_UNIFIEDPUSH_URL');
const unifiedPushAuthHeader = nconf.get('PUSH_CONFIGS_UNIFIEDPUSH_AUTHORIZATION');
let endpoint = pushDevice.regId;
let resolvedEndpoint;
try {
if (/^https?:\/\//i.test(endpoint)) {
resolvedEndpoint = endpoint;
} else if (unifiedPushBaseUrl) {
resolvedEndpoint = new URL(endpoint, unifiedPushBaseUrl).toString();
} else {
throw new Error('Unified Push regId is not a full URL and no base URL configured');
}
} catch (err) {
logger.error(err, {
extraMessage: 'Unable to resolve Unified Push endpoint.',
regId: pushDevice.regId,
userId: user._id,
});
return;
}
const body = {
title: details.title,
message: details.message,
identifier: details.identifier,
payload,
};
const options = {
json: body,
timeout: 30000,
};
if (unifiedPushAuthHeader) {
options.headers = {
Authorization: unifiedPushAuthHeader,
};
}
try {
await got.post(resolvedEndpoint, options);
} catch (err) {
const statusCode = err?.response?.statusCode;
if (statusCode && [404, 410].includes(statusCode)) {
removePushDevice(user, pushDevice);
logger.error(new Error('Unified Push endpoint is no longer valid'), {
regId: pushDevice.regId,
userId: user._id,
statusCode,
});
return;
}
logger.error(err, {
extraMessage: 'Unhandled Unified Push error.',
regId: pushDevice.regId,
userId: user._id,
statusCode,
});
}
}
export async function sendNotification (user, details = {}) { export async function sendNotification (user, details = {}) {
if (!user) throw new Error('User is required.'); if (!user) throw new Error('User is required.');
if (user.preferences.pushNotifications.unsubscribeFromAll === true) return; if (user.preferences.pushNotifications.unsubscribeFromAll === true) return;
@ -148,6 +215,9 @@ export async function sendNotification (user, details = {}) {
case 'ios': case 'ios':
sendAPNNotification(user, pushDevice, details, payload); sendAPNNotification(user, pushDevice, details, payload);
break; break;
case 'unifiedpush':
await sendUnifiedPushNotification(user, pushDevice, details, payload);
break;
} }
}); });
} }

View file

@ -6,7 +6,7 @@ const { Schema } = mongoose;
export const schema = new Schema({ export const schema = new Schema({
regId: { $type: String, required: true }, regId: { $type: String, required: true },
type: { $type: String, required: true, enum: ['ios', 'android'] }, type: { $type: String, required: true, enum: ['ios', 'android', 'unifiedpush'] },
}, { }, {
strict: true, strict: true,
minimize: false, // So empty objects are returned minimize: false, // So empty objects are returned