1
0

FCM.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. 'use strict';
  2. import Parse from 'parse';
  3. import log from 'npmlog';
  4. import { initializeApp, cert, getApps, getApp } from 'firebase-admin/app';
  5. import { getMessaging } from 'firebase-admin/messaging';
  6. import { randomString } from './PushAdapterUtils.js';
  7. const LOG_PREFIX = 'parse-server-push-adapter FCM';
  8. const FCMRegistrationTokensMax = 500;
  9. const FCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // FCM allows a max of 4 weeks
  10. const apnsIntegerDataKeys = [
  11. 'badge',
  12. 'content-available',
  13. 'mutable-content',
  14. 'priority',
  15. 'expiration_time',
  16. ];
  17. export default function FCM(args, pushType) {
  18. if (typeof args !== 'object' || !args.firebaseServiceAccount) {
  19. throw new Parse.Error(
  20. Parse.Error.PUSH_MISCONFIGURED,
  21. 'FCM Configuration is invalid',
  22. );
  23. }
  24. let app;
  25. if (getApps().length === 0) {
  26. app = initializeApp({ credential: cert(args.firebaseServiceAccount) });
  27. } else {
  28. app = getApp();
  29. }
  30. this.sender = getMessaging(app);
  31. this.pushType = pushType; // Push type is only used to remain backwards compatible with APNS and GCM
  32. }
  33. FCM.FCMRegistrationTokensMax = FCMRegistrationTokensMax;
  34. /**
  35. * Send fcm request.
  36. * @param {Object} data The data we need to send, the format is the same with api request body
  37. * @param {Array} devices A array of devices
  38. * @returns {Object} Array of resolved promises
  39. */
  40. FCM.prototype.send = function (data, devices) {
  41. if (!data || !devices || !Array.isArray(devices)) {
  42. log.warn(LOG_PREFIX, 'invalid push payload');
  43. return;
  44. }
  45. // We can only have 500 recepients per send, so we need to slice devices to
  46. // chunk if necessary
  47. const slices = sliceDevices(devices, FCM.FCMRegistrationTokensMax);
  48. const sendToDeviceSlice = (deviceSlice, pushType) => {
  49. const pushId = randomString(10);
  50. const timestamp = Date.now();
  51. // Build a device map
  52. const devicesMap = deviceSlice.reduce((memo, device) => {
  53. memo[device.deviceToken] = device;
  54. return memo;
  55. }, {});
  56. const deviceTokens = Object.keys(devicesMap);
  57. const fcmPayload = generateFCMPayload(
  58. data,
  59. pushId,
  60. timestamp,
  61. deviceTokens,
  62. pushType,
  63. );
  64. const length = deviceTokens.length;
  65. log.info(LOG_PREFIX, `sending push to ${length} devices`);
  66. return this.sender
  67. .sendEachForMulticast(fcmPayload.data)
  68. .then((response) => {
  69. const promises = [];
  70. const failedTokens = [];
  71. const successfulTokens = [];
  72. response.responses.forEach((resp, idx) => {
  73. if (resp.success) {
  74. successfulTokens.push(deviceTokens[idx]);
  75. promises.push(
  76. createSuccessfulPromise(
  77. deviceTokens[idx],
  78. devicesMap[deviceTokens[idx]].deviceType,
  79. ),
  80. );
  81. } else {
  82. failedTokens.push(deviceTokens[idx]);
  83. promises.push(
  84. createErrorPromise(
  85. deviceTokens[idx],
  86. devicesMap[deviceTokens[idx]].deviceType,
  87. resp.error,
  88. ),
  89. );
  90. log.error(
  91. LOG_PREFIX,
  92. `failed to send to ${deviceTokens[idx]} with error: ${JSON.stringify(resp.error)}`,
  93. );
  94. }
  95. });
  96. if (failedTokens.length) {
  97. log.error(
  98. LOG_PREFIX,
  99. `tokens with failed pushes: ${JSON.stringify(failedTokens)}`,
  100. );
  101. }
  102. if (successfulTokens.length) {
  103. log.verbose(
  104. LOG_PREFIX,
  105. `tokens with successful pushes: ${JSON.stringify(successfulTokens)}`,
  106. );
  107. }
  108. return Promise.all(promises);
  109. });
  110. };
  111. const allPromises = Promise.all(
  112. slices.map((slice) => sendToDeviceSlice(slice, this.pushType)),
  113. ).catch((err) => {
  114. log.error(LOG_PREFIX, `error sending push: ${err}`);
  115. });
  116. return allPromises;
  117. };
  118. function _APNSToFCMPayload(requestData) {
  119. let coreData = requestData;
  120. if (requestData.hasOwnProperty('data')) {
  121. coreData = requestData.data;
  122. }
  123. const expirationTime =
  124. requestData['expiration_time'] || coreData['expiration_time'];
  125. const collapseId = requestData['collapse_id'] || coreData['collapse_id'];
  126. const pushType = requestData['push_type'] || coreData['push_type'];
  127. const priority = requestData['priority'] || coreData['priority'];
  128. const apnsPayload = { apns: { payload: { aps: {} } } };
  129. const headers = {};
  130. // Set to alert by default if not set explicitly
  131. headers['apns-push-type'] = 'alert';
  132. if (expirationTime) {
  133. headers['apns-expiration'] = Math.round(expirationTime / 1000);
  134. }
  135. if (collapseId) {
  136. headers['apns-collapse-id'] = collapseId;
  137. }
  138. if (pushType) {
  139. headers['apns-push-type'] = pushType;
  140. }
  141. if (priority) {
  142. headers['apns-priority'] = priority;
  143. }
  144. if (Object.keys(headers).length > 0) {
  145. apnsPayload.apns.headers = headers;
  146. }
  147. for (const key in coreData) {
  148. switch (key) {
  149. case 'aps':
  150. apnsPayload['apns']['payload']['aps'] = coreData.aps;
  151. break;
  152. case 'alert':
  153. if (typeof coreData.alert == 'object') {
  154. // When we receive a dictionary, use as is to remain
  155. // compatible with how the APNS.js + node-apn work
  156. apnsPayload['apns']['payload']['aps']['alert'] = coreData.alert;
  157. } else {
  158. // When we receive a value, prepare `alert` dictionary
  159. // and set its `body` property
  160. apnsPayload['apns']['payload']['aps']['alert'] = {};
  161. apnsPayload['apns']['payload']['aps']['alert']['body'] = coreData.alert;
  162. }
  163. break;
  164. case 'title':
  165. // Ensure the alert object exists before trying to assign the title
  166. // title always goes into the nested `alert` dictionary
  167. if (!apnsPayload['apns']['payload']['aps'].hasOwnProperty('alert')) {
  168. apnsPayload['apns']['payload']['aps']['alert'] = {};
  169. }
  170. apnsPayload['apns']['payload']['aps']['alert']['title'] = coreData.title;
  171. break;
  172. case 'badge':
  173. apnsPayload['apns']['payload']['aps']['badge'] = coreData.badge;
  174. break;
  175. case 'sound':
  176. apnsPayload['apns']['payload']['aps']['sound'] = coreData.sound;
  177. break;
  178. case 'content-available':
  179. apnsPayload['apns']['payload']['aps']['content-available'] =
  180. coreData['content-available'];
  181. break;
  182. case 'mutable-content':
  183. apnsPayload['apns']['payload']['aps']['mutable-content'] =
  184. coreData['mutable-content'];
  185. break;
  186. case 'targetContentIdentifier':
  187. apnsPayload['apns']['payload']['aps']['target-content-id'] =
  188. coreData.targetContentIdentifier;
  189. break;
  190. case 'interruptionLevel':
  191. apnsPayload['apns']['payload']['aps']['interruption-level'] =
  192. coreData.interruptionLevel;
  193. break;
  194. case 'category':
  195. apnsPayload['apns']['payload']['aps']['category'] = coreData.category;
  196. break;
  197. case 'threadId':
  198. apnsPayload['apns']['payload']['aps']['thread-id'] = coreData.threadId;
  199. break;
  200. case 'expiration_time': // Exclude header-related fields as these are set above
  201. break;
  202. case 'collapse_id':
  203. break;
  204. case 'push_type':
  205. break;
  206. case 'priority':
  207. break;
  208. default:
  209. apnsPayload['apns']['payload'][key] = coreData[key]; // Custom keys should be outside aps
  210. break;
  211. }
  212. }
  213. return apnsPayload;
  214. }
  215. function _GCMToFCMPayload(requestData, pushId, timeStamp) {
  216. const androidPayload = {
  217. android: {
  218. priority: 'high',
  219. },
  220. };
  221. if (requestData.hasOwnProperty('notification')) {
  222. androidPayload.android.notification = requestData.notification;
  223. }
  224. if (requestData.hasOwnProperty('data')) {
  225. // FCM gives an error on send if we have apns keys that should have integer values
  226. for (const key of apnsIntegerDataKeys) {
  227. if (requestData.data.hasOwnProperty(key)) {
  228. delete requestData.data[key]
  229. }
  230. }
  231. androidPayload.android.data = {
  232. push_id: pushId,
  233. time: new Date(timeStamp).toISOString(),
  234. data: JSON.stringify(requestData.data),
  235. }
  236. }
  237. if (requestData['expiration_time']) {
  238. const expirationTime = requestData['expiration_time'];
  239. // Convert to seconds
  240. let timeToLive = Math.floor((expirationTime - timeStamp) / 1000);
  241. if (timeToLive < 0) {
  242. timeToLive = 0;
  243. }
  244. if (timeToLive >= FCMTimeToLiveMax) {
  245. timeToLive = FCMTimeToLiveMax;
  246. }
  247. androidPayload.android.ttl = timeToLive;
  248. }
  249. return androidPayload;
  250. }
  251. /**
  252. * Converts payloads used by APNS or GCM into a FCMv1-compatible payload.
  253. * Purpose is to remain backwards-compatible will payloads used in the APNS.js and GCM.js modules.
  254. * If the key rawPayload is present in the requestData, a raw payload will be used. Otherwise, conversion is done.
  255. * @param {Object} requestData The request body
  256. * @param {String} pushType Either apple or android.
  257. * @param {String} pushId Used during GCM payload conversion, required by Parse Android SDK.
  258. * @param {Number} timeStamp Used during GCM payload conversion for ttl, required by Parse Android SDK.
  259. * @returns {Object} A FCMv1-compatible payload.
  260. */
  261. function payloadConverter(requestData, pushType, pushId, timeStamp) {
  262. if (requestData.hasOwnProperty('rawPayload')) {
  263. return requestData.rawPayload;
  264. }
  265. if (pushType === 'apple') {
  266. return _APNSToFCMPayload(requestData);
  267. } else if (pushType === 'android') {
  268. return _GCMToFCMPayload(requestData, pushId, timeStamp);
  269. } else {
  270. throw new Parse.Error(
  271. Parse.Error.PUSH_MISCONFIGURED,
  272. 'Unsupported push type, apple or android only.',
  273. );
  274. }
  275. }
  276. /**
  277. * Generate the fcm payload from the data we get from api request.
  278. * @param {Object} requestData The request body
  279. * @param {String} pushId A random string
  280. * @param {Number} timeStamp A number in milliseconds since the Unix Epoch
  281. * @param {Array.<String>} deviceTokens An array of deviceTokens
  282. * @param {String} pushType Either apple or android
  283. * @returns {Object} A payload for FCM
  284. */
  285. function generateFCMPayload(
  286. requestData,
  287. pushId,
  288. timeStamp,
  289. deviceTokens,
  290. pushType,
  291. ) {
  292. delete requestData['where'];
  293. const payloadToUse = {
  294. data: {}
  295. };
  296. const fcmPayload = payloadConverter(requestData, pushType, pushId, timeStamp);
  297. payloadToUse.data = {
  298. ...fcmPayload,
  299. tokens: deviceTokens,
  300. };
  301. return payloadToUse;
  302. }
  303. /**
  304. * Slice a list of devices to several list of devices with fixed chunk size.
  305. * @param {Array} devices An array of devices
  306. * @param {Number} chunkSize The size of the a chunk
  307. * @returns {Array} An array which contains several arrays of devices with fixed chunk size
  308. */
  309. function sliceDevices(devices, chunkSize) {
  310. const chunkDevices = [];
  311. while (devices.length > 0) {
  312. chunkDevices.push(devices.splice(0, chunkSize));
  313. }
  314. return chunkDevices;
  315. }
  316. /**
  317. * Creates an errorPromise for return.
  318. *
  319. * @param {String} token Device-Token
  320. * @param {String} deviceType Device-Type
  321. * @param {String} errorMessage ErrrorMessage as string
  322. */
  323. function createErrorPromise(token, deviceType, errorMessage) {
  324. return Promise.resolve({
  325. transmitted: false,
  326. device: {
  327. deviceToken: token,
  328. deviceType: deviceType,
  329. },
  330. response: { error: errorMessage },
  331. });
  332. }
  333. /**
  334. * Creates an successfulPromise for return.
  335. *
  336. * @param {String} token Device-Token
  337. * @param {String} deviceType Device-Type
  338. */
  339. function createSuccessfulPromise(token, deviceType) {
  340. return Promise.resolve({
  341. transmitted: true,
  342. device: {
  343. deviceToken: token,
  344. deviceType: deviceType,
  345. },
  346. });
  347. }
  348. FCM.generateFCMPayload = generateFCMPayload;
  349. /* istanbul ignore else */
  350. if (process.env.TESTING) {
  351. FCM.sliceDevices = sliceDevices;
  352. }