414 lines
13 KiB
JavaScript
Executable File
414 lines
13 KiB
JavaScript
Executable File
'use strict';
|
|
|
|
const url = require('url');
|
|
const https = require('https');
|
|
|
|
const WebPushError = require('./web-push-error.js');
|
|
const vapidHelper = require('./vapid-helper.js');
|
|
const encryptionHelper = require('./encryption-helper.js');
|
|
const webPushConstants = require('./web-push-constants.js');
|
|
const urlBase64Helper = require('./urlsafe-base64-helper');
|
|
|
|
// Default TTL is four weeks.
|
|
const DEFAULT_TTL = 2419200;
|
|
|
|
let gcmAPIKey = '';
|
|
let vapidDetails;
|
|
|
|
function WebPushLib() {
|
|
|
|
}
|
|
|
|
/**
|
|
* When sending messages to a GCM endpoint you need to set the GCM API key
|
|
* by either calling setGMAPIKey() or passing in the API key as an option
|
|
* to sendNotification().
|
|
* @param {string} apiKey The API key to send with the GCM request.
|
|
*/
|
|
WebPushLib.prototype.setGCMAPIKey = function(apiKey) {
|
|
if (apiKey === null) {
|
|
gcmAPIKey = null;
|
|
return;
|
|
}
|
|
|
|
if (typeof apiKey === 'undefined'
|
|
|| typeof apiKey !== 'string'
|
|
|| apiKey.length === 0) {
|
|
throw new Error('The GCM API Key should be a non-empty string or null.');
|
|
}
|
|
|
|
gcmAPIKey = apiKey;
|
|
};
|
|
|
|
/**
|
|
* When making requests where you want to define VAPID details, call this
|
|
* method before sendNotification() or pass in the details and options to
|
|
* sendNotification.
|
|
* @param {string} subject This must be either a URL or a 'mailto:'
|
|
* address. For example: 'https://my-site.com/contact' or
|
|
* 'mailto: contact@my-site.com'
|
|
* @param {string} publicKey The public VAPID key, a URL safe, base64 encoded string
|
|
* @param {string} privateKey The private VAPID key, a URL safe, base64 encoded string.
|
|
*/
|
|
WebPushLib.prototype.setVapidDetails = function(subject, publicKey, privateKey) {
|
|
if (arguments.length === 1 && arguments[0] === null) {
|
|
vapidDetails = null;
|
|
return;
|
|
}
|
|
|
|
vapidHelper.validateSubject(subject);
|
|
vapidHelper.validatePublicKey(publicKey);
|
|
vapidHelper.validatePrivateKey(privateKey);
|
|
|
|
vapidDetails = {
|
|
subject: subject,
|
|
publicKey: publicKey,
|
|
privateKey: privateKey
|
|
};
|
|
};
|
|
|
|
/**
|
|
* To get the details of a request to trigger a push message, without sending
|
|
* a push notification call this method.
|
|
*
|
|
* This method will throw an error if there is an issue with the input.
|
|
* @param {PushSubscription} subscription The PushSubscription you wish to
|
|
* send the notification to.
|
|
* @param {string|Buffer} [payload] The payload you wish to send to the
|
|
* the user.
|
|
* @param {Object} [options] Options for the GCM API key and
|
|
* vapid keys can be passed in if they are unique for each notification you
|
|
* wish to send.
|
|
* @return {Object} This method returns an Object which
|
|
* contains 'endpoint', 'method', 'headers' and 'payload'.
|
|
*/
|
|
WebPushLib.prototype.generateRequestDetails = function(subscription, payload, options) {
|
|
if (!subscription || !subscription.endpoint) {
|
|
throw new Error('You must pass in a subscription with at least '
|
|
+ 'an endpoint.');
|
|
}
|
|
|
|
if (typeof subscription.endpoint !== 'string'
|
|
|| subscription.endpoint.length === 0) {
|
|
throw new Error('The subscription endpoint must be a string with '
|
|
+ 'a valid URL.');
|
|
}
|
|
|
|
if (payload) {
|
|
// Validate the subscription keys
|
|
if (typeof subscription !== 'object' || !subscription.keys
|
|
|| !subscription.keys.p256dh
|
|
|| !subscription.keys.auth) {
|
|
throw new Error('To send a message with a payload, the '
|
|
+ 'subscription must have \'auth\' and \'p256dh\' keys.');
|
|
}
|
|
}
|
|
|
|
let currentGCMAPIKey = gcmAPIKey;
|
|
let currentVapidDetails = vapidDetails;
|
|
let timeToLive = DEFAULT_TTL;
|
|
let extraHeaders = {};
|
|
let contentEncoding = webPushConstants.supportedContentEncodings.AES_128_GCM;
|
|
let urgency = webPushConstants.supportedUrgency.NORMAL;
|
|
let topic;
|
|
let proxy;
|
|
let agent;
|
|
let timeout;
|
|
|
|
if (options) {
|
|
const validOptionKeys = [
|
|
'headers',
|
|
'gcmAPIKey',
|
|
'vapidDetails',
|
|
'TTL',
|
|
'contentEncoding',
|
|
'urgency',
|
|
'topic',
|
|
'proxy',
|
|
'agent',
|
|
'timeout'
|
|
];
|
|
const optionKeys = Object.keys(options);
|
|
for (let i = 0; i < optionKeys.length; i += 1) {
|
|
const optionKey = optionKeys[i];
|
|
if (!validOptionKeys.includes(optionKey)) {
|
|
throw new Error('\'' + optionKey + '\' is an invalid option. '
|
|
+ 'The valid options are [\'' + validOptionKeys.join('\', \'')
|
|
+ '\'].');
|
|
}
|
|
}
|
|
|
|
if (options.headers) {
|
|
extraHeaders = options.headers;
|
|
let duplicates = Object.keys(extraHeaders)
|
|
.filter(function (header) {
|
|
return typeof options[header] !== 'undefined';
|
|
});
|
|
|
|
if (duplicates.length > 0) {
|
|
throw new Error('Duplicated headers defined ['
|
|
+ duplicates.join(',') + ']. Please either define the header in the'
|
|
+ 'top level options OR in the \'headers\' key.');
|
|
}
|
|
}
|
|
|
|
if (options.gcmAPIKey) {
|
|
currentGCMAPIKey = options.gcmAPIKey;
|
|
}
|
|
|
|
// Falsy values are allowed here so one can skip Vapid `else if` below and use FCM
|
|
if (options.vapidDetails !== undefined) {
|
|
currentVapidDetails = options.vapidDetails;
|
|
}
|
|
|
|
if (options.TTL !== undefined) {
|
|
timeToLive = Number(options.TTL);
|
|
if (timeToLive < 0) {
|
|
throw new Error('TTL should be a number and should be at least 0');
|
|
}
|
|
}
|
|
|
|
if (options.contentEncoding) {
|
|
if ((options.contentEncoding === webPushConstants.supportedContentEncodings.AES_128_GCM
|
|
|| options.contentEncoding === webPushConstants.supportedContentEncodings.AES_GCM)) {
|
|
contentEncoding = options.contentEncoding;
|
|
} else {
|
|
throw new Error('Unsupported content encoding specified.');
|
|
}
|
|
}
|
|
|
|
if (options.urgency) {
|
|
if ((options.urgency === webPushConstants.supportedUrgency.VERY_LOW
|
|
|| options.urgency === webPushConstants.supportedUrgency.LOW
|
|
|| options.urgency === webPushConstants.supportedUrgency.NORMAL
|
|
|| options.urgency === webPushConstants.supportedUrgency.HIGH)) {
|
|
urgency = options.urgency;
|
|
} else {
|
|
throw new Error('Unsupported urgency specified.');
|
|
}
|
|
}
|
|
|
|
if (options.topic) {
|
|
if (!urlBase64Helper.validate(options.topic)) {
|
|
throw new Error('Unsupported characters set use the URL or filename-safe Base64 characters set');
|
|
}
|
|
if (options.topic.length > 32) {
|
|
throw new Error('use maximum of 32 characters from the URL or filename-safe Base64 characters set');
|
|
}
|
|
topic = options.topic;
|
|
}
|
|
|
|
if (options.proxy) {
|
|
if (typeof options.proxy === 'string'
|
|
|| typeof options.proxy.host === 'string') {
|
|
proxy = options.proxy;
|
|
} else {
|
|
console.warn('Attempt to use proxy option, but invalid type it should be a string or proxy options object.');
|
|
}
|
|
}
|
|
|
|
if (options.agent) {
|
|
if (options.agent instanceof https.Agent) {
|
|
if (proxy) {
|
|
console.warn('Agent option will be ignored because proxy option is defined.');
|
|
}
|
|
|
|
agent = options.agent;
|
|
} else {
|
|
console.warn('Wrong type for the agent option, it should be an instance of https.Agent.');
|
|
}
|
|
}
|
|
|
|
if (typeof options.timeout === 'number') {
|
|
timeout = options.timeout;
|
|
}
|
|
}
|
|
|
|
if (typeof timeToLive === 'undefined') {
|
|
timeToLive = DEFAULT_TTL;
|
|
}
|
|
|
|
const requestDetails = {
|
|
method: 'POST',
|
|
headers: {
|
|
TTL: timeToLive
|
|
}
|
|
};
|
|
Object.keys(extraHeaders).forEach(function (header) {
|
|
requestDetails.headers[header] = extraHeaders[header];
|
|
});
|
|
let requestPayload = null;
|
|
|
|
if (payload) {
|
|
const encrypted = encryptionHelper
|
|
.encrypt(subscription.keys.p256dh, subscription.keys.auth, payload, contentEncoding);
|
|
|
|
requestDetails.headers['Content-Length'] = encrypted.cipherText.length;
|
|
requestDetails.headers['Content-Type'] = 'application/octet-stream';
|
|
|
|
if (contentEncoding === webPushConstants.supportedContentEncodings.AES_128_GCM) {
|
|
requestDetails.headers['Content-Encoding'] = webPushConstants.supportedContentEncodings.AES_128_GCM;
|
|
} else if (contentEncoding === webPushConstants.supportedContentEncodings.AES_GCM) {
|
|
requestDetails.headers['Content-Encoding'] = webPushConstants.supportedContentEncodings.AES_GCM;
|
|
requestDetails.headers.Encryption = 'salt=' + encrypted.salt;
|
|
requestDetails.headers['Crypto-Key'] = 'dh=' + encrypted.localPublicKey.toString('base64url');
|
|
}
|
|
|
|
requestPayload = encrypted.cipherText;
|
|
} else {
|
|
requestDetails.headers['Content-Length'] = 0;
|
|
}
|
|
|
|
const isGCM = subscription.endpoint.startsWith('https://android.googleapis.com/gcm/send');
|
|
const isFCM = subscription.endpoint.startsWith('https://fcm.googleapis.com/fcm/send');
|
|
// VAPID isn't supported by GCM hence the if, else if.
|
|
if (isGCM) {
|
|
if (!currentGCMAPIKey) {
|
|
console.warn('Attempt to send push notification to GCM endpoint, '
|
|
+ 'but no GCM key is defined. Please use setGCMApiKey() or add '
|
|
+ '\'gcmAPIKey\' as an option.');
|
|
} else {
|
|
requestDetails.headers.Authorization = 'key=' + currentGCMAPIKey;
|
|
}
|
|
} else if (currentVapidDetails) {
|
|
const parsedUrl = url.parse(subscription.endpoint);
|
|
const audience = parsedUrl.protocol + '//'
|
|
+ parsedUrl.host;
|
|
|
|
const vapidHeaders = vapidHelper.getVapidHeaders(
|
|
audience,
|
|
currentVapidDetails.subject,
|
|
currentVapidDetails.publicKey,
|
|
currentVapidDetails.privateKey,
|
|
contentEncoding
|
|
);
|
|
|
|
requestDetails.headers.Authorization = vapidHeaders.Authorization;
|
|
|
|
if (contentEncoding === webPushConstants.supportedContentEncodings.AES_GCM) {
|
|
if (requestDetails.headers['Crypto-Key']) {
|
|
requestDetails.headers['Crypto-Key'] += ';'
|
|
+ vapidHeaders['Crypto-Key'];
|
|
} else {
|
|
requestDetails.headers['Crypto-Key'] = vapidHeaders['Crypto-Key'];
|
|
}
|
|
}
|
|
} else if (isFCM && currentGCMAPIKey) {
|
|
requestDetails.headers.Authorization = 'key=' + currentGCMAPIKey;
|
|
}
|
|
|
|
requestDetails.headers.Urgency = urgency;
|
|
|
|
if (topic) {
|
|
requestDetails.headers.Topic = topic;
|
|
}
|
|
|
|
requestDetails.body = requestPayload;
|
|
requestDetails.endpoint = subscription.endpoint;
|
|
|
|
if (proxy) {
|
|
requestDetails.proxy = proxy;
|
|
}
|
|
|
|
if (agent) {
|
|
requestDetails.agent = agent;
|
|
}
|
|
|
|
if (timeout) {
|
|
requestDetails.timeout = timeout;
|
|
}
|
|
|
|
return requestDetails;
|
|
};
|
|
|
|
/**
|
|
* To send a push notification call this method with a subscription, optional
|
|
* payload and any options.
|
|
* @param {PushSubscription} subscription The PushSubscription you wish to
|
|
* send the notification to.
|
|
* @param {string|Buffer} [payload] The payload you wish to send to the
|
|
* the user.
|
|
* @param {Object} [options] Options for the GCM API key and
|
|
* vapid keys can be passed in if they are unique for each notification you
|
|
* wish to send.
|
|
* @return {Promise} This method returns a Promise which
|
|
* resolves if the sending of the notification was successful, otherwise it
|
|
* rejects.
|
|
*/
|
|
WebPushLib.prototype.sendNotification = function(subscription, payload, options) {
|
|
let requestDetails;
|
|
try {
|
|
requestDetails = this.generateRequestDetails(subscription, payload, options);
|
|
} catch (err) {
|
|
return Promise.reject(err);
|
|
}
|
|
|
|
return new Promise(function(resolve, reject) {
|
|
const httpsOptions = {};
|
|
const urlParts = url.parse(requestDetails.endpoint);
|
|
httpsOptions.hostname = urlParts.hostname;
|
|
httpsOptions.port = urlParts.port;
|
|
httpsOptions.path = urlParts.path;
|
|
|
|
httpsOptions.headers = requestDetails.headers;
|
|
httpsOptions.method = requestDetails.method;
|
|
|
|
if (requestDetails.timeout) {
|
|
httpsOptions.timeout = requestDetails.timeout;
|
|
}
|
|
|
|
if (requestDetails.agent) {
|
|
httpsOptions.agent = requestDetails.agent;
|
|
}
|
|
|
|
if (requestDetails.proxy) {
|
|
const { HttpsProxyAgent } = require('https-proxy-agent'); // eslint-disable-line global-require
|
|
httpsOptions.agent = new HttpsProxyAgent(requestDetails.proxy);
|
|
}
|
|
|
|
const pushRequest = https.request(httpsOptions, function(pushResponse) {
|
|
let responseText = '';
|
|
|
|
pushResponse.on('data', function(chunk) {
|
|
responseText += chunk;
|
|
});
|
|
|
|
pushResponse.on('end', function() {
|
|
if (pushResponse.statusCode < 200 || pushResponse.statusCode > 299) {
|
|
reject(new WebPushError(
|
|
'Received unexpected response code',
|
|
pushResponse.statusCode,
|
|
pushResponse.headers,
|
|
responseText,
|
|
requestDetails.endpoint
|
|
));
|
|
} else {
|
|
resolve({
|
|
statusCode: pushResponse.statusCode,
|
|
body: responseText,
|
|
headers: pushResponse.headers
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
if (requestDetails.timeout) {
|
|
pushRequest.on('timeout', function() {
|
|
pushRequest.destroy(new Error('Socket timeout'));
|
|
});
|
|
}
|
|
|
|
pushRequest.on('error', function(e) {
|
|
reject(e);
|
|
});
|
|
|
|
if (requestDetails.body) {
|
|
pushRequest.write(requestDetails.body);
|
|
}
|
|
|
|
pushRequest.end();
|
|
});
|
|
};
|
|
|
|
module.exports = WebPushLib;
|