1
0

web-push-lib.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. 'use strict';
  2. const url = require('url');
  3. const https = require('https');
  4. const WebPushError = require('./web-push-error.js');
  5. const vapidHelper = require('./vapid-helper.js');
  6. const encryptionHelper = require('./encryption-helper.js');
  7. const webPushConstants = require('./web-push-constants.js');
  8. const urlBase64Helper = require('./urlsafe-base64-helper');
  9. // Default TTL is four weeks.
  10. const DEFAULT_TTL = 2419200;
  11. let gcmAPIKey = '';
  12. let vapidDetails;
  13. function WebPushLib() {
  14. }
  15. /**
  16. * When sending messages to a GCM endpoint you need to set the GCM API key
  17. * by either calling setGMAPIKey() or passing in the API key as an option
  18. * to sendNotification().
  19. * @param {string} apiKey The API key to send with the GCM request.
  20. */
  21. WebPushLib.prototype.setGCMAPIKey = function(apiKey) {
  22. if (apiKey === null) {
  23. gcmAPIKey = null;
  24. return;
  25. }
  26. if (typeof apiKey === 'undefined'
  27. || typeof apiKey !== 'string'
  28. || apiKey.length === 0) {
  29. throw new Error('The GCM API Key should be a non-empty string or null.');
  30. }
  31. gcmAPIKey = apiKey;
  32. };
  33. /**
  34. * When making requests where you want to define VAPID details, call this
  35. * method before sendNotification() or pass in the details and options to
  36. * sendNotification.
  37. * @param {string} subject This must be either a URL or a 'mailto:'
  38. * address. For example: 'https://my-site.com/contact' or
  39. * 'mailto: contact@my-site.com'
  40. * @param {string} publicKey The public VAPID key, a URL safe, base64 encoded string
  41. * @param {string} privateKey The private VAPID key, a URL safe, base64 encoded string.
  42. */
  43. WebPushLib.prototype.setVapidDetails = function(subject, publicKey, privateKey) {
  44. if (arguments.length === 1 && arguments[0] === null) {
  45. vapidDetails = null;
  46. return;
  47. }
  48. vapidHelper.validateSubject(subject);
  49. vapidHelper.validatePublicKey(publicKey);
  50. vapidHelper.validatePrivateKey(privateKey);
  51. vapidDetails = {
  52. subject: subject,
  53. publicKey: publicKey,
  54. privateKey: privateKey
  55. };
  56. };
  57. /**
  58. * To get the details of a request to trigger a push message, without sending
  59. * a push notification call this method.
  60. *
  61. * This method will throw an error if there is an issue with the input.
  62. * @param {PushSubscription} subscription The PushSubscription you wish to
  63. * send the notification to.
  64. * @param {string|Buffer} [payload] The payload you wish to send to the
  65. * the user.
  66. * @param {Object} [options] Options for the GCM API key and
  67. * vapid keys can be passed in if they are unique for each notification you
  68. * wish to send.
  69. * @return {Object} This method returns an Object which
  70. * contains 'endpoint', 'method', 'headers' and 'payload'.
  71. */
  72. WebPushLib.prototype.generateRequestDetails = function(subscription, payload, options) {
  73. if (!subscription || !subscription.endpoint) {
  74. throw new Error('You must pass in a subscription with at least '
  75. + 'an endpoint.');
  76. }
  77. if (typeof subscription.endpoint !== 'string'
  78. || subscription.endpoint.length === 0) {
  79. throw new Error('The subscription endpoint must be a string with '
  80. + 'a valid URL.');
  81. }
  82. if (payload) {
  83. // Validate the subscription keys
  84. if (typeof subscription !== 'object' || !subscription.keys
  85. || !subscription.keys.p256dh
  86. || !subscription.keys.auth) {
  87. throw new Error('To send a message with a payload, the '
  88. + 'subscription must have \'auth\' and \'p256dh\' keys.');
  89. }
  90. }
  91. let currentGCMAPIKey = gcmAPIKey;
  92. let currentVapidDetails = vapidDetails;
  93. let timeToLive = DEFAULT_TTL;
  94. let extraHeaders = {};
  95. let contentEncoding = webPushConstants.supportedContentEncodings.AES_128_GCM;
  96. let urgency = webPushConstants.supportedUrgency.NORMAL;
  97. let topic;
  98. let proxy;
  99. let agent;
  100. let timeout;
  101. if (options) {
  102. const validOptionKeys = [
  103. 'headers',
  104. 'gcmAPIKey',
  105. 'vapidDetails',
  106. 'TTL',
  107. 'contentEncoding',
  108. 'urgency',
  109. 'topic',
  110. 'proxy',
  111. 'agent',
  112. 'timeout'
  113. ];
  114. const optionKeys = Object.keys(options);
  115. for (let i = 0; i < optionKeys.length; i += 1) {
  116. const optionKey = optionKeys[i];
  117. if (!validOptionKeys.includes(optionKey)) {
  118. throw new Error('\'' + optionKey + '\' is an invalid option. '
  119. + 'The valid options are [\'' + validOptionKeys.join('\', \'')
  120. + '\'].');
  121. }
  122. }
  123. if (options.headers) {
  124. extraHeaders = options.headers;
  125. let duplicates = Object.keys(extraHeaders)
  126. .filter(function (header) {
  127. return typeof options[header] !== 'undefined';
  128. });
  129. if (duplicates.length > 0) {
  130. throw new Error('Duplicated headers defined ['
  131. + duplicates.join(',') + ']. Please either define the header in the'
  132. + 'top level options OR in the \'headers\' key.');
  133. }
  134. }
  135. if (options.gcmAPIKey) {
  136. currentGCMAPIKey = options.gcmAPIKey;
  137. }
  138. // Falsy values are allowed here so one can skip Vapid `else if` below and use FCM
  139. if (options.vapidDetails !== undefined) {
  140. currentVapidDetails = options.vapidDetails;
  141. }
  142. if (options.TTL !== undefined) {
  143. timeToLive = Number(options.TTL);
  144. if (timeToLive < 0) {
  145. throw new Error('TTL should be a number and should be at least 0');
  146. }
  147. }
  148. if (options.contentEncoding) {
  149. if ((options.contentEncoding === webPushConstants.supportedContentEncodings.AES_128_GCM
  150. || options.contentEncoding === webPushConstants.supportedContentEncodings.AES_GCM)) {
  151. contentEncoding = options.contentEncoding;
  152. } else {
  153. throw new Error('Unsupported content encoding specified.');
  154. }
  155. }
  156. if (options.urgency) {
  157. if ((options.urgency === webPushConstants.supportedUrgency.VERY_LOW
  158. || options.urgency === webPushConstants.supportedUrgency.LOW
  159. || options.urgency === webPushConstants.supportedUrgency.NORMAL
  160. || options.urgency === webPushConstants.supportedUrgency.HIGH)) {
  161. urgency = options.urgency;
  162. } else {
  163. throw new Error('Unsupported urgency specified.');
  164. }
  165. }
  166. if (options.topic) {
  167. if (!urlBase64Helper.validate(options.topic)) {
  168. throw new Error('Unsupported characters set use the URL or filename-safe Base64 characters set');
  169. }
  170. if (options.topic.length > 32) {
  171. throw new Error('use maximum of 32 characters from the URL or filename-safe Base64 characters set');
  172. }
  173. topic = options.topic;
  174. }
  175. if (options.proxy) {
  176. if (typeof options.proxy === 'string'
  177. || typeof options.proxy.host === 'string') {
  178. proxy = options.proxy;
  179. } else {
  180. console.warn('Attempt to use proxy option, but invalid type it should be a string or proxy options object.');
  181. }
  182. }
  183. if (options.agent) {
  184. if (options.agent instanceof https.Agent) {
  185. if (proxy) {
  186. console.warn('Agent option will be ignored because proxy option is defined.');
  187. }
  188. agent = options.agent;
  189. } else {
  190. console.warn('Wrong type for the agent option, it should be an instance of https.Agent.');
  191. }
  192. }
  193. if (typeof options.timeout === 'number') {
  194. timeout = options.timeout;
  195. }
  196. }
  197. if (typeof timeToLive === 'undefined') {
  198. timeToLive = DEFAULT_TTL;
  199. }
  200. const requestDetails = {
  201. method: 'POST',
  202. headers: {
  203. TTL: timeToLive
  204. }
  205. };
  206. Object.keys(extraHeaders).forEach(function (header) {
  207. requestDetails.headers[header] = extraHeaders[header];
  208. });
  209. let requestPayload = null;
  210. if (payload) {
  211. const encrypted = encryptionHelper
  212. .encrypt(subscription.keys.p256dh, subscription.keys.auth, payload, contentEncoding);
  213. requestDetails.headers['Content-Length'] = encrypted.cipherText.length;
  214. requestDetails.headers['Content-Type'] = 'application/octet-stream';
  215. if (contentEncoding === webPushConstants.supportedContentEncodings.AES_128_GCM) {
  216. requestDetails.headers['Content-Encoding'] = webPushConstants.supportedContentEncodings.AES_128_GCM;
  217. } else if (contentEncoding === webPushConstants.supportedContentEncodings.AES_GCM) {
  218. requestDetails.headers['Content-Encoding'] = webPushConstants.supportedContentEncodings.AES_GCM;
  219. requestDetails.headers.Encryption = 'salt=' + encrypted.salt;
  220. requestDetails.headers['Crypto-Key'] = 'dh=' + encrypted.localPublicKey.toString('base64url');
  221. }
  222. requestPayload = encrypted.cipherText;
  223. } else {
  224. requestDetails.headers['Content-Length'] = 0;
  225. }
  226. const isGCM = subscription.endpoint.startsWith('https://android.googleapis.com/gcm/send');
  227. const isFCM = subscription.endpoint.startsWith('https://fcm.googleapis.com/fcm/send');
  228. // VAPID isn't supported by GCM hence the if, else if.
  229. if (isGCM) {
  230. if (!currentGCMAPIKey) {
  231. console.warn('Attempt to send push notification to GCM endpoint, '
  232. + 'but no GCM key is defined. Please use setGCMApiKey() or add '
  233. + '\'gcmAPIKey\' as an option.');
  234. } else {
  235. requestDetails.headers.Authorization = 'key=' + currentGCMAPIKey;
  236. }
  237. } else if (currentVapidDetails) {
  238. const parsedUrl = url.parse(subscription.endpoint);
  239. const audience = parsedUrl.protocol + '//'
  240. + parsedUrl.host;
  241. const vapidHeaders = vapidHelper.getVapidHeaders(
  242. audience,
  243. currentVapidDetails.subject,
  244. currentVapidDetails.publicKey,
  245. currentVapidDetails.privateKey,
  246. contentEncoding
  247. );
  248. requestDetails.headers.Authorization = vapidHeaders.Authorization;
  249. if (contentEncoding === webPushConstants.supportedContentEncodings.AES_GCM) {
  250. if (requestDetails.headers['Crypto-Key']) {
  251. requestDetails.headers['Crypto-Key'] += ';'
  252. + vapidHeaders['Crypto-Key'];
  253. } else {
  254. requestDetails.headers['Crypto-Key'] = vapidHeaders['Crypto-Key'];
  255. }
  256. }
  257. } else if (isFCM && currentGCMAPIKey) {
  258. requestDetails.headers.Authorization = 'key=' + currentGCMAPIKey;
  259. }
  260. requestDetails.headers.Urgency = urgency;
  261. if (topic) {
  262. requestDetails.headers.Topic = topic;
  263. }
  264. requestDetails.body = requestPayload;
  265. requestDetails.endpoint = subscription.endpoint;
  266. if (proxy) {
  267. requestDetails.proxy = proxy;
  268. }
  269. if (agent) {
  270. requestDetails.agent = agent;
  271. }
  272. if (timeout) {
  273. requestDetails.timeout = timeout;
  274. }
  275. return requestDetails;
  276. };
  277. /**
  278. * To send a push notification call this method with a subscription, optional
  279. * payload and any options.
  280. * @param {PushSubscription} subscription The PushSubscription you wish to
  281. * send the notification to.
  282. * @param {string|Buffer} [payload] The payload you wish to send to the
  283. * the user.
  284. * @param {Object} [options] Options for the GCM API key and
  285. * vapid keys can be passed in if they are unique for each notification you
  286. * wish to send.
  287. * @return {Promise} This method returns a Promise which
  288. * resolves if the sending of the notification was successful, otherwise it
  289. * rejects.
  290. */
  291. WebPushLib.prototype.sendNotification = function(subscription, payload, options) {
  292. let requestDetails;
  293. try {
  294. requestDetails = this.generateRequestDetails(subscription, payload, options);
  295. } catch (err) {
  296. return Promise.reject(err);
  297. }
  298. return new Promise(function(resolve, reject) {
  299. const httpsOptions = {};
  300. const urlParts = url.parse(requestDetails.endpoint);
  301. httpsOptions.hostname = urlParts.hostname;
  302. httpsOptions.port = urlParts.port;
  303. httpsOptions.path = urlParts.path;
  304. httpsOptions.headers = requestDetails.headers;
  305. httpsOptions.method = requestDetails.method;
  306. if (requestDetails.timeout) {
  307. httpsOptions.timeout = requestDetails.timeout;
  308. }
  309. if (requestDetails.agent) {
  310. httpsOptions.agent = requestDetails.agent;
  311. }
  312. if (requestDetails.proxy) {
  313. const { HttpsProxyAgent } = require('https-proxy-agent'); // eslint-disable-line global-require
  314. httpsOptions.agent = new HttpsProxyAgent(requestDetails.proxy);
  315. }
  316. const pushRequest = https.request(httpsOptions, function(pushResponse) {
  317. let responseText = '';
  318. pushResponse.on('data', function(chunk) {
  319. responseText += chunk;
  320. });
  321. pushResponse.on('end', function() {
  322. if (pushResponse.statusCode < 200 || pushResponse.statusCode > 299) {
  323. reject(new WebPushError(
  324. 'Received unexpected response code',
  325. pushResponse.statusCode,
  326. pushResponse.headers,
  327. responseText,
  328. requestDetails.endpoint
  329. ));
  330. } else {
  331. resolve({
  332. statusCode: pushResponse.statusCode,
  333. body: responseText,
  334. headers: pushResponse.headers
  335. });
  336. }
  337. });
  338. });
  339. if (requestDetails.timeout) {
  340. pushRequest.on('timeout', function() {
  341. pushRequest.destroy(new Error('Socket timeout'));
  342. });
  343. }
  344. pushRequest.on('error', function(e) {
  345. reject(e);
  346. });
  347. if (requestDetails.body) {
  348. pushRequest.write(requestDetails.body);
  349. }
  350. pushRequest.end();
  351. });
  352. };
  353. module.exports = WebPushLib;