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_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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue