From 34fd36e784b7490ce8d8c76a5382102b9830bf09 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 17 Sep 2025 17:28:17 -0600 Subject: [PATCH] Add UnifiedPush push notification support --- config.json.example | 2 + test/api/unit/libs/pushNotifications.test.js | 78 ++++++++++++++++++- .../controllers/api-v3/pushNotifications.js | 2 +- website/server/libs/pushNotifications.js | 70 +++++++++++++++++ website/server/models/pushDevice.js | 2 +- 5 files changed, 151 insertions(+), 3 deletions(-) diff --git a/config.json.example b/config.json.example index 11218f0ea9..f8ebb3dbbf 100644 --- a/config.json.example +++ b/config.json.example @@ -58,6 +58,8 @@ "PUSH_CONFIGS_APN_KEY_ID": "xxxxxxxxxx", "PUSH_CONFIGS_APN_TEAM_ID": "aaabbbcccd", "PUSH_CONFIGS_FCM_SERVER_API_KEY": "aaabbbcccd", + "PUSH_CONFIGS_UNIFIEDPUSH_URL": "", + "PUSH_CONFIGS_UNIFIEDPUSH_AUTHORIZATION": "", "S3_ACCESS_KEY_ID": "accessKeyId", "S3_BUCKET": "bucket", "S3_SECRET_ACCESS_KEY": "secretAccessKey", diff --git a/test/api/unit/libs/pushNotifications.test.js b/test/api/unit/libs/pushNotifications.test.js index 96a6197526..07458ea3a3 100644 --- a/test/api/unit/libs/pushNotifications.test.js +++ b/test/api/unit/libs/pushNotifications.test.js @@ -2,6 +2,7 @@ import apn from '@parse/node-apn'; import _ from 'lodash'; import nconf from 'nconf'; import admin from 'firebase-admin'; +import got from 'got'; import { model as User } from '../../../../website/server/models/user'; import { MAX_MESSAGE_LENGTH, @@ -15,6 +16,7 @@ describe('pushNotifications', () => { let apnSendSpy; let updateStub; let classStubbedInstance; + let gotPostStub; const identifier = 'identifier'; const title = 'title'; @@ -24,8 +26,10 @@ describe('pushNotifications', () => { user = new User(); fcmSendSpy = sinon.stub().returns(Promise.resolve('success')); apnSendSpy = sinon.stub().returns(Promise.resolve()); + gotPostStub = sandbox.stub(got, 'post').resolves(); nconf.set('PUSH_CONFIGS_APN_ENABLED', 'true'); + nconf.set('PUSH_CONFIGS_UNIFIEDPUSH_URL', 'https://push.example.com/'); classStubbedInstance = sandbox.createStubInstance(apn.Provider, { send: apnSendSpy, @@ -197,6 +201,7 @@ describe('pushNotifications', () => { expect(apnSendSpy).to.have.been.calledOnce; expect(apnSendSpy).to.have.been.calledWithMatch(expectedNotification, '123'); expect(fcmSendSpy).to.not.have.been.called; + expect(gotPostStub).to.not.have.been.called; }); it('uses FCM for Android devices', async () => { @@ -221,6 +226,49 @@ describe('pushNotifications', () => { expect(fcmSendSpy).to.have.been.calledOnce; expect(fcmSendSpy).to.have.been.calledWithMatch(expectedMessage); 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 () => { @@ -236,13 +284,17 @@ describe('pushNotifications', () => { type: 'android', regId: '789', }); + user.pushDevices.push({ + type: 'unifiedpush', + regId: 'unified', + }); await sendPushNotification(user, details); expect(fcmSendSpy).to.have.been.calledTwice; expect(apnSendSpy).to.have.been.calledOnce; + expect(gotPostStub).to.have.been.calledOnce; }); }); - describe('handles sending errors', () => { let clock; @@ -324,6 +376,29 @@ describe('pushNotifications', () => { 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 () => { user.pushDevices.push({ type: 'ios', @@ -350,5 +425,6 @@ describe('pushNotifications', () => { expect(apnSendSpy).to.have.been.calledOnce; expect(updateStub).to.have.been.calledOnce; }); + }); }); diff --git a/website/server/controllers/api-v3/pushNotifications.js b/website/server/controllers/api-v3/pushNotifications.js index 1b9c8a929a..c6ec45366f 100644 --- a/website/server/controllers/api-v3/pushNotifications.js +++ b/website/server/controllers/api-v3/pushNotifications.js @@ -26,7 +26,7 @@ api.addPushDevice = { const { user } = res.locals; 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(); if (validationErrors) throw validationErrors; diff --git a/website/server/libs/pushNotifications.js b/website/server/libs/pushNotifications.js index 267685b840..66fa675593 100644 --- a/website/server/libs/pushNotifications.js +++ b/website/server/libs/pushNotifications.js @@ -2,6 +2,7 @@ import _ from 'lodash'; import nconf from 'nconf'; import apn from '@parse/node-apn'; import admin from 'firebase-admin'; +import got from 'got'; import logger from './logger'; import { // eslint-disable-line import/no-cycle 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 = {}) { if (!user) throw new Error('User is required.'); if (user.preferences.pushNotifications.unsubscribeFromAll === true) return; @@ -148,6 +215,9 @@ export async function sendNotification (user, details = {}) { case 'ios': sendAPNNotification(user, pushDevice, details, payload); break; + case 'unifiedpush': + await sendUnifiedPushNotification(user, pushDevice, details, payload); + break; } }); } diff --git a/website/server/models/pushDevice.js b/website/server/models/pushDevice.js index 6410201ea2..fccac546de 100644 --- a/website/server/models/pushDevice.js +++ b/website/server/models/pushDevice.js @@ -6,7 +6,7 @@ const { Schema } = mongoose; export const schema = new Schema({ regId: { $type: String, required: true }, - type: { $type: String, required: true, enum: ['ios', 'android'] }, + type: { $type: String, required: true, enum: ['ios', 'android', 'unifiedpush'] }, }, { strict: true, minimize: false, // So empty objects are returned