mirror of
https://github.com/sudoxnym/habitica-self-host.git
synced 2026-04-14 11:36:45 +00:00
Add UnifiedPush push notification support
This commit is contained in:
parent
2166ef72bb
commit
34fd36e784
5 changed files with 151 additions and 3 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue