1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5"use strict";
6
7const { AppConstants } = ChromeUtils.import(
8  "resource://gre/modules/AppConstants.jsm"
9);
10const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
11const { clearTimeout, setTimeout } = ChromeUtils.import(
12  "resource://gre/modules/Timer.jsm"
13);
14const { XPCOMUtils } = ChromeUtils.import(
15  "resource://gre/modules/XPCOMUtils.jsm"
16);
17
18var PushServiceWebSocket, PushServiceHttp2;
19
20XPCOMUtils.defineLazyServiceGetter(
21  this,
22  "gPushNotifier",
23  "@mozilla.org/push/Notifier;1",
24  "nsIPushNotifier"
25);
26XPCOMUtils.defineLazyServiceGetter(
27  this,
28  "eTLDService",
29  "@mozilla.org/network/effective-tld-service;1",
30  "nsIEffectiveTLDService"
31);
32ChromeUtils.defineModuleGetter(
33  this,
34  "pushBroadcastService",
35  "resource://gre/modules/PushBroadcastService.jsm"
36);
37ChromeUtils.defineModuleGetter(
38  this,
39  "PushCrypto",
40  "resource://gre/modules/PushCrypto.jsm"
41);
42ChromeUtils.defineModuleGetter(
43  this,
44  "PushServiceAndroidGCM",
45  "resource://gre/modules/PushServiceAndroidGCM.jsm"
46);
47
48const CONNECTION_PROTOCOLS = (function() {
49  if ("android" != AppConstants.MOZ_WIDGET_TOOLKIT) {
50    ({ PushServiceWebSocket } = ChromeUtils.import(
51      "resource://gre/modules/PushServiceWebSocket.jsm"
52    ));
53    ({ PushServiceHttp2 } = ChromeUtils.import(
54      "resource://gre/modules/PushServiceHttp2.jsm"
55    ));
56    return [PushServiceWebSocket, PushServiceHttp2];
57  }
58  return [PushServiceAndroidGCM];
59})();
60
61const EXPORTED_SYMBOLS = ["PushService"];
62
63XPCOMUtils.defineLazyGetter(this, "console", () => {
64  let { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm");
65  return new ConsoleAPI({
66    maxLogLevelPref: "dom.push.loglevel",
67    prefix: "PushService",
68  });
69});
70
71const prefs = Services.prefs.getBranch("dom.push.");
72
73const PUSH_SERVICE_UNINIT = 0;
74const PUSH_SERVICE_INIT = 1; // No serverURI
75const PUSH_SERVICE_ACTIVATING = 2; // activating db
76const PUSH_SERVICE_CONNECTION_DISABLE = 3;
77const PUSH_SERVICE_ACTIVE_OFFLINE = 4;
78const PUSH_SERVICE_RUNNING = 5;
79
80/**
81 * State is change only in couple of functions:
82 *   init - change state to PUSH_SERVICE_INIT if state was PUSH_SERVICE_UNINIT
83 *   changeServerURL - change state to PUSH_SERVICE_ACTIVATING if serverURL
84 *                     present or PUSH_SERVICE_INIT if not present.
85 *   changeStateConnectionEnabledEvent - it is call on pref change or during
86 *                                       the service activation and it can
87 *                                       change state to
88 *                                       PUSH_SERVICE_CONNECTION_DISABLE
89 *   changeStateOfflineEvent - it is called when offline state changes or during
90 *                             the service activation and it change state to
91 *                             PUSH_SERVICE_ACTIVE_OFFLINE or
92 *                             PUSH_SERVICE_RUNNING.
93 *   uninit - change state to PUSH_SERVICE_UNINIT.
94 **/
95
96// This is for starting and stopping service.
97const STARTING_SERVICE_EVENT = 0;
98const CHANGING_SERVICE_EVENT = 1;
99const STOPPING_SERVICE_EVENT = 2;
100const UNINIT_EVENT = 3;
101
102// Returns the backend for the given server URI.
103function getServiceForServerURI(uri) {
104  // Insecure server URLs are allowed for development and testing.
105  let allowInsecure = prefs.getBoolPref(
106    "testing.allowInsecureServerURL",
107    false
108  );
109  if (AppConstants.MOZ_WIDGET_TOOLKIT == "android") {
110    if (uri.scheme == "https" || (allowInsecure && uri.scheme == "http")) {
111      return CONNECTION_PROTOCOLS;
112    }
113    return null;
114  }
115  if (uri.scheme == "wss" || (allowInsecure && uri.scheme == "ws")) {
116    return PushServiceWebSocket;
117  }
118  if (uri.scheme == "https" || (allowInsecure && uri.scheme == "http")) {
119    return PushServiceHttp2;
120  }
121  return null;
122}
123
124/**
125 * Annotates an error with an XPCOM result code. We use this helper
126 * instead of `Components.Exception` because the latter can assert in
127 * `nsXPCComponents_Exception::HasInstance` when inspected at shutdown.
128 */
129function errorWithResult(message, result = Cr.NS_ERROR_FAILURE) {
130  let error = new Error(message);
131  error.result = result;
132  return error;
133}
134
135/**
136 * The implementation of the push system. It uses WebSockets
137 * (PushServiceWebSocket) to communicate with the server and PushDB (IndexedDB)
138 * for persistence.
139 */
140var PushService = {
141  _service: null,
142  _state: PUSH_SERVICE_UNINIT,
143  _db: null,
144  _options: null,
145  _visibleNotifications: new Map(),
146
147  // Callback that is called after attempting to
148  // reduce the quota for a record. Used for testing purposes.
149  _updateQuotaTestCallback: null,
150
151  // Set of timeout ID of tasks to reduce quota.
152  _updateQuotaTimeouts: new Set(),
153
154  // When serverURI changes (this is used for testing), db is cleaned up and a
155  // a new db is started. This events must be sequential.
156  _stateChangeProcessQueue: null,
157  _stateChangeProcessEnqueue(op) {
158    if (!this._stateChangeProcessQueue) {
159      this._stateChangeProcessQueue = Promise.resolve();
160    }
161
162    this._stateChangeProcessQueue = this._stateChangeProcessQueue
163      .then(op)
164      .catch(error => {
165        console.error(
166          "stateChangeProcessEnqueue: Error transitioning state",
167          error
168        );
169        return this._shutdownService();
170      })
171      .catch(error => {
172        console.error(
173          "stateChangeProcessEnqueue: Error shutting down service",
174          error
175        );
176      });
177    return this._stateChangeProcessQueue;
178  },
179
180  // Pending request. If a worker try to register for the same scope again, do
181  // not send a new registration request. Therefore we need queue of pending
182  // register requests. This is the list of scopes which pending registration.
183  _pendingRegisterRequest: {},
184  _notifyActivated: null,
185  _activated: null,
186  _checkActivated() {
187    if (this._state < PUSH_SERVICE_ACTIVATING) {
188      return Promise.reject(new Error("Push service not active"));
189    }
190    if (this._state > PUSH_SERVICE_ACTIVATING) {
191      return Promise.resolve();
192    }
193    if (!this._activated) {
194      this._activated = new Promise((resolve, reject) => {
195        this._notifyActivated = { resolve, reject };
196      });
197    }
198    return this._activated;
199  },
200
201  _makePendingKey(aPageRecord) {
202    return aPageRecord.scope + "|" + aPageRecord.originAttributes;
203  },
204
205  _lookupOrPutPendingRequest(aPageRecord) {
206    let key = this._makePendingKey(aPageRecord);
207    if (this._pendingRegisterRequest[key]) {
208      return this._pendingRegisterRequest[key];
209    }
210
211    return (this._pendingRegisterRequest[key] = this._registerWithServer(
212      aPageRecord
213    ));
214  },
215
216  _deletePendingRequest(aPageRecord) {
217    let key = this._makePendingKey(aPageRecord);
218    if (this._pendingRegisterRequest[key]) {
219      delete this._pendingRegisterRequest[key];
220    }
221  },
222
223  _setState(aNewState) {
224    console.debug(
225      "setState()",
226      "new state",
227      aNewState,
228      "old state",
229      this._state
230    );
231
232    if (this._state == aNewState) {
233      return;
234    }
235
236    if (this._state == PUSH_SERVICE_ACTIVATING) {
237      // It is not important what is the new state as soon as we leave
238      // PUSH_SERVICE_ACTIVATING
239      if (this._notifyActivated) {
240        if (aNewState < PUSH_SERVICE_ACTIVATING) {
241          this._notifyActivated.reject(new Error("Push service not active"));
242        } else {
243          this._notifyActivated.resolve();
244        }
245      }
246      this._notifyActivated = null;
247      this._activated = null;
248    }
249    this._state = aNewState;
250  },
251
252  async _changeStateOfflineEvent(offline, calledFromConnEnabledEvent) {
253    console.debug("changeStateOfflineEvent()", offline);
254
255    if (
256      this._state < PUSH_SERVICE_ACTIVE_OFFLINE &&
257      this._state != PUSH_SERVICE_ACTIVATING &&
258      !calledFromConnEnabledEvent
259    ) {
260      return;
261    }
262
263    if (offline) {
264      if (this._state == PUSH_SERVICE_RUNNING) {
265        this._service.disconnect();
266      }
267      this._setState(PUSH_SERVICE_ACTIVE_OFFLINE);
268      return;
269    }
270
271    if (this._state == PUSH_SERVICE_RUNNING) {
272      // PushService was not in the offline state, but got notification to
273      // go online (a offline notification has not been sent).
274      // Disconnect first.
275      this._service.disconnect();
276    }
277
278    let broadcastListeners = await pushBroadcastService.getListeners();
279
280    // In principle, a listener could be added to the
281    // pushBroadcastService here, after we have gotten listeners and
282    // before we're RUNNING, but this can't happen in practice because
283    // the only caller that can add listeners is PushBroadcastService,
284    // and it waits on the same promise we are before it can add
285    // listeners. If PushBroadcastService gets woken first, it will
286    // update the value that is eventually returned from
287    // getListeners.
288    this._setState(PUSH_SERVICE_RUNNING);
289
290    this._service.connect(broadcastListeners);
291  },
292
293  _changeStateConnectionEnabledEvent(enabled) {
294    console.debug("changeStateConnectionEnabledEvent()", enabled);
295
296    if (
297      this._state < PUSH_SERVICE_CONNECTION_DISABLE &&
298      this._state != PUSH_SERVICE_ACTIVATING
299    ) {
300      return Promise.resolve();
301    }
302
303    if (enabled) {
304      return this._changeStateOfflineEvent(Services.io.offline, true);
305    }
306
307    if (this._state == PUSH_SERVICE_RUNNING) {
308      this._service.disconnect();
309    }
310    this._setState(PUSH_SERVICE_CONNECTION_DISABLE);
311    return Promise.resolve();
312  },
313
314  // Used for testing.
315  changeTestServer(url, options = {}) {
316    console.debug("changeTestServer()");
317
318    return this._stateChangeProcessEnqueue(_ => {
319      if (this._state < PUSH_SERVICE_ACTIVATING) {
320        console.debug("changeTestServer: PushService not activated?");
321        return Promise.resolve();
322      }
323
324      return this._changeServerURL(url, CHANGING_SERVICE_EVENT, options);
325    });
326  },
327
328  observe: function observe(aSubject, aTopic, aData) {
329    switch (aTopic) {
330      /*
331       * We need to call uninit() on shutdown to clean up things that modules
332       * aren't very good at automatically cleaning up, so we don't get shutdown
333       * leaks on browser shutdown.
334       */
335      case "quit-application":
336        this.uninit();
337        break;
338      case "network:offline-status-changed":
339        this._stateChangeProcessEnqueue(_ =>
340          this._changeStateOfflineEvent(aData === "offline", false)
341        );
342        break;
343
344      case "nsPref:changed":
345        if (aData == "serverURL") {
346          console.debug(
347            "observe: dom.push.serverURL changed for websocket",
348            prefs.getStringPref("serverURL")
349          );
350          this._stateChangeProcessEnqueue(_ =>
351            this._changeServerURL(
352              prefs.getStringPref("serverURL"),
353              CHANGING_SERVICE_EVENT
354            )
355          );
356        } else if (aData == "connection.enabled") {
357          this._stateChangeProcessEnqueue(_ =>
358            this._changeStateConnectionEnabledEvent(
359              prefs.getBoolPref("connection.enabled")
360            )
361          );
362        }
363        break;
364
365      case "idle-daily":
366        this._dropExpiredRegistrations().catch(error => {
367          console.error("Failed to drop expired registrations on idle", error);
368        });
369        break;
370
371      case "perm-changed":
372        this._onPermissionChange(aSubject, aData).catch(error => {
373          console.error(
374            "onPermissionChange: Error updating registrations:",
375            error
376          );
377        });
378        break;
379
380      case "clear-origin-attributes-data":
381        this._clearOriginData(aData).catch(error => {
382          console.error("clearOriginData: Error clearing origin data:", error);
383        });
384        break;
385    }
386  },
387
388  _clearOriginData(data) {
389    console.log("clearOriginData()");
390
391    if (!data) {
392      return Promise.resolve();
393    }
394
395    let pattern = JSON.parse(data);
396    return this._dropRegistrationsIf(record =>
397      record.matchesOriginAttributes(pattern)
398    );
399  },
400
401  /**
402   * Sends an unregister request to the server in the background. If the
403   * service is not connected, this function is a no-op.
404   *
405   * @param {PushRecord} record The record to unregister.
406   * @param {Number} reason An `nsIPushErrorReporter` unsubscribe reason,
407   *  indicating why this record was removed.
408   */
409  _backgroundUnregister(record, reason) {
410    console.debug("backgroundUnregister()");
411
412    if (!this._service.isConnected() || !record) {
413      return;
414    }
415
416    console.debug("backgroundUnregister: Notifying server", record);
417    this._sendUnregister(record, reason)
418      .then(() => {
419        gPushNotifier.notifySubscriptionModified(
420          record.scope,
421          record.principal
422        );
423      })
424      .catch(e => {
425        console.error("backgroundUnregister: Error notifying server", e);
426      });
427  },
428
429  _findService(serverURL) {
430    console.debug("findService()");
431
432    if (!serverURL) {
433      console.warn("findService: No dom.push.serverURL found");
434      return [];
435    }
436
437    let uri;
438    try {
439      uri = Services.io.newURI(serverURL);
440    } catch (e) {
441      console.warn(
442        "findService: Error creating valid URI from",
443        "dom.push.serverURL",
444        serverURL
445      );
446      return [];
447    }
448
449    let service = getServiceForServerURI(uri);
450    return [service, uri];
451  },
452
453  _changeServerURL(serverURI, event, options = {}) {
454    console.debug("changeServerURL()");
455
456    switch (event) {
457      case UNINIT_EVENT:
458        return this._stopService(event);
459
460      case STARTING_SERVICE_EVENT: {
461        let [service, uri] = this._findService(serverURI);
462        if (!service) {
463          this._setState(PUSH_SERVICE_INIT);
464          return Promise.resolve();
465        }
466        return this._startService(service, uri, options).then(_ =>
467          this._changeStateConnectionEnabledEvent(
468            prefs.getBoolPref("connection.enabled")
469          )
470        );
471      }
472      case CHANGING_SERVICE_EVENT:
473        let [service, uri] = this._findService(serverURI);
474        if (service) {
475          if (this._state == PUSH_SERVICE_INIT) {
476            this._setState(PUSH_SERVICE_ACTIVATING);
477            // The service has not been running - start it.
478            return this._startService(service, uri, options).then(_ =>
479              this._changeStateConnectionEnabledEvent(
480                prefs.getBoolPref("connection.enabled")
481              )
482            );
483          }
484          this._setState(PUSH_SERVICE_ACTIVATING);
485          // If we already had running service - stop service, start the new
486          // one and check connection.enabled and offline state(offline state
487          // check is called in changeStateConnectionEnabledEvent function)
488          return this._stopService(CHANGING_SERVICE_EVENT)
489            .then(_ => this._startService(service, uri, options))
490            .then(_ =>
491              this._changeStateConnectionEnabledEvent(
492                prefs.getBoolPref("connection.enabled")
493              )
494            );
495        }
496        if (this._state == PUSH_SERVICE_INIT) {
497          return Promise.resolve();
498        }
499        // The new serverUri is empty or misconfigured - stop service.
500        this._setState(PUSH_SERVICE_INIT);
501        return this._stopService(STOPPING_SERVICE_EVENT);
502
503      default:
504        console.error("Unexpected event in _changeServerURL", event);
505        return Promise.reject(new Error(`Unexpected event ${event}`));
506    }
507  },
508
509  /**
510   * PushService initialization is divided into 4 parts:
511   * init() - start listening for quit-application and serverURL changes.
512   *          state is change to PUSH_SERVICE_INIT
513   * startService() - if serverURL is present this function is called. It starts
514   *                  listening for broadcasted messages, starts db and
515   *                  PushService connection (WebSocket).
516   *                  state is change to PUSH_SERVICE_ACTIVATING.
517   * startObservers() - start other observers.
518   * changeStateConnectionEnabledEvent  - checks prefs and offline state.
519   *                                      It changes state to:
520   *                                        PUSH_SERVICE_RUNNING,
521   *                                        PUSH_SERVICE_ACTIVE_OFFLINE or
522   *                                        PUSH_SERVICE_CONNECTION_DISABLE.
523   */
524  async init(options = {}) {
525    console.debug("init()");
526
527    if (this._state > PUSH_SERVICE_UNINIT) {
528      return;
529    }
530
531    this._setState(PUSH_SERVICE_ACTIVATING);
532
533    prefs.addObserver("serverURL", this);
534    Services.obs.addObserver(this, "quit-application");
535
536    if (options.serverURI) {
537      // this is use for xpcshell test.
538
539      await this._stateChangeProcessEnqueue(_ =>
540        this._changeServerURL(
541          options.serverURI,
542          STARTING_SERVICE_EVENT,
543          options
544        )
545      );
546    } else {
547      // This is only used for testing. Different tests require connecting to
548      // slightly different URLs.
549      await this._stateChangeProcessEnqueue(_ =>
550        this._changeServerURL(
551          prefs.getStringPref("serverURL"),
552          STARTING_SERVICE_EVENT
553        )
554      );
555    }
556  },
557
558  _startObservers() {
559    console.debug("startObservers()");
560
561    if (this._state != PUSH_SERVICE_ACTIVATING) {
562      return;
563    }
564
565    Services.obs.addObserver(this, "clear-origin-attributes-data");
566
567    // The offline-status-changed event is used to know
568    // when to (dis)connect. It may not fire if the underlying OS changes
569    // networks; in such a case we rely on timeout.
570    Services.obs.addObserver(this, "network:offline-status-changed");
571
572    // Used to monitor if the user wishes to disable Push.
573    prefs.addObserver("connection.enabled", this);
574
575    // Prunes expired registrations and notifies dormant service workers.
576    Services.obs.addObserver(this, "idle-daily");
577
578    // Prunes registrations for sites for which the user revokes push
579    // permissions.
580    Services.obs.addObserver(this, "perm-changed");
581  },
582
583  _startService(service, serverURI, options) {
584    console.debug("startService()");
585
586    if (this._state != PUSH_SERVICE_ACTIVATING) {
587      return Promise.reject();
588    }
589
590    this._service = service;
591
592    this._db = options.db;
593    if (!this._db) {
594      this._db = this._service.newPushDB();
595    }
596
597    return this._service.init(options, this, serverURI).then(() => {
598      this._startObservers();
599      return this._dropExpiredRegistrations();
600    });
601  },
602
603  /**
604   * PushService uninitialization is divided into 3 parts:
605   * stopObservers() - stot observers started in startObservers.
606   * stopService() - It stops listening for broadcasted messages, stops db and
607   *                 PushService connection (WebSocket).
608   *                 state is changed to PUSH_SERVICE_INIT.
609   * uninit() - stop listening for quit-application and serverURL changes.
610   *            state is change to PUSH_SERVICE_UNINIT
611   */
612  _stopService(event) {
613    console.debug("stopService()");
614
615    if (this._state < PUSH_SERVICE_ACTIVATING) {
616      return Promise.resolve();
617    }
618
619    this._stopObservers();
620
621    this._service.disconnect();
622    this._service.uninit();
623    this._service = null;
624
625    this._updateQuotaTimeouts.forEach(timeoutID => clearTimeout(timeoutID));
626    this._updateQuotaTimeouts.clear();
627
628    if (!this._db) {
629      return Promise.resolve();
630    }
631    if (event == UNINIT_EVENT) {
632      // If it is uninitialized just close db.
633      this._db.close();
634      this._db = null;
635      return Promise.resolve();
636    }
637
638    return this.dropUnexpiredRegistrations().then(
639      _ => {
640        this._db.close();
641        this._db = null;
642      },
643      err => {
644        this._db.close();
645        this._db = null;
646      }
647    );
648  },
649
650  _stopObservers() {
651    console.debug("stopObservers()");
652
653    if (this._state < PUSH_SERVICE_ACTIVATING) {
654      return;
655    }
656
657    prefs.removeObserver("connection.enabled", this);
658
659    Services.obs.removeObserver(this, "network:offline-status-changed");
660    Services.obs.removeObserver(this, "clear-origin-attributes-data");
661    Services.obs.removeObserver(this, "idle-daily");
662    Services.obs.removeObserver(this, "perm-changed");
663  },
664
665  _shutdownService() {
666    let promiseChangeURL = this._changeServerURL("", UNINIT_EVENT);
667    this._setState(PUSH_SERVICE_UNINIT);
668    console.debug("shutdownService: shutdown complete!");
669    return promiseChangeURL;
670  },
671
672  async uninit() {
673    console.debug("uninit()");
674
675    if (this._state == PUSH_SERVICE_UNINIT) {
676      return;
677    }
678
679    prefs.removeObserver("serverURL", this);
680    Services.obs.removeObserver(this, "quit-application");
681
682    await this._stateChangeProcessEnqueue(_ => this._shutdownService());
683  },
684
685  /**
686   * Drops all active registrations and notifies the associated service
687   * workers. This function is called when the user switches Push servers,
688   * or when the server invalidates all existing registrations.
689   *
690   * We ignore expired registrations because they're already handled in other
691   * code paths. Registrations that expired after exceeding their quotas are
692   * evicted at startup, or on the next `idle-daily` event. Registrations that
693   * expired because the user revoked the notification permission are evicted
694   * once the permission is reinstated.
695   */
696  dropUnexpiredRegistrations() {
697    return this._db.clearIf(record => {
698      if (record.isExpired()) {
699        return false;
700      }
701      this._notifySubscriptionChangeObservers(record);
702      return true;
703    });
704  },
705
706  _notifySubscriptionChangeObservers(record) {
707    if (!record) {
708      return;
709    }
710    gPushNotifier.notifySubscriptionChange(record.scope, record.principal);
711  },
712
713  /**
714   * Drops a registration and notifies the associated service worker. If the
715   * registration does not exist, this function is a no-op.
716   *
717   * @param {String} keyID The registration ID to remove.
718   * @returns {Promise} Resolves once the worker has been notified.
719   */
720  dropRegistrationAndNotifyApp(aKeyID) {
721    return this._db
722      .delete(aKeyID)
723      .then(record => this._notifySubscriptionChangeObservers(record));
724  },
725
726  /**
727   * Replaces an existing registration and notifies the associated service
728   * worker.
729   *
730   * @param {String} aOldKey The registration ID to replace.
731   * @param {PushRecord} aNewRecord The new record.
732   * @returns {Promise} Resolves once the worker has been notified.
733   */
734  updateRegistrationAndNotifyApp(aOldKey, aNewRecord) {
735    return this.updateRecordAndNotifyApp(aOldKey, _ => aNewRecord);
736  },
737  /**
738   * Updates a registration and notifies the associated service worker.
739   *
740   * @param {String} keyID The registration ID to update.
741   * @param {Function} updateFunc Returns the updated record.
742   * @returns {Promise} Resolves with the updated record once the worker
743   *  has been notified.
744   */
745  updateRecordAndNotifyApp(aKeyID, aUpdateFunc) {
746    return this._db.update(aKeyID, aUpdateFunc).then(record => {
747      this._notifySubscriptionChangeObservers(record);
748      return record;
749    });
750  },
751
752  ensureCrypto(record) {
753    if (
754      record.hasAuthenticationSecret() &&
755      record.p256dhPublicKey &&
756      record.p256dhPrivateKey
757    ) {
758      return Promise.resolve(record);
759    }
760
761    let keygen = Promise.resolve([]);
762    if (!record.p256dhPublicKey || !record.p256dhPrivateKey) {
763      keygen = PushCrypto.generateKeys();
764    }
765    // We do not have a encryption key. so we need to generate it. This
766    // is only going to happen on db upgrade from version 4 to higher.
767    return keygen.then(
768      ([pubKey, privKey]) => {
769        return this.updateRecordAndNotifyApp(record.keyID, record => {
770          if (!record.p256dhPublicKey || !record.p256dhPrivateKey) {
771            record.p256dhPublicKey = pubKey;
772            record.p256dhPrivateKey = privKey;
773          }
774          if (!record.hasAuthenticationSecret()) {
775            record.authenticationSecret = PushCrypto.generateAuthenticationSecret();
776          }
777          return record;
778        });
779      },
780      error => {
781        return this.dropRegistrationAndNotifyApp(record.keyID).then(() =>
782          Promise.reject(error)
783        );
784      }
785    );
786  },
787
788  /**
789   * Dispatches an incoming message to a service worker, recalculating the
790   * quota for the associated push registration. If the quota is exceeded,
791   * the registration and message will be dropped, and the worker will not
792   * be notified.
793   *
794   * @param {String} keyID The push registration ID.
795   * @param {String} messageID The message ID, used to report service worker
796   *  delivery failures. For Web Push messages, this is the version. If empty,
797   *  failures will not be reported.
798   * @param {Object} headers The encryption headers.
799   * @param {ArrayBuffer|Uint8Array} data The encrypted message data.
800   * @param {Function} updateFunc A function that receives the existing
801   *  registration record as its argument, and returns a new record. If the
802   *  function returns `null` or `undefined`, the record will not be updated.
803   *  `PushServiceWebSocket` uses this to drop incoming updates with older
804   *  versions.
805   * @returns {Promise} Resolves with an `nsIPushErrorReporter` ack status
806   *  code, indicating whether the message was delivered successfully.
807   */
808  receivedPushMessage(keyID, messageID, headers, data, updateFunc) {
809    console.debug("receivedPushMessage()");
810
811    return this._updateRecordAfterPush(keyID, updateFunc)
812      .then(record => {
813        if (record.quotaApplies()) {
814          // Update quota after the delay, at which point
815          // we check for visible notifications.
816          let timeoutID = setTimeout(_ => {
817            this._updateQuota(keyID);
818            if (!this._updateQuotaTimeouts.delete(timeoutID)) {
819              console.debug(
820                "receivedPushMessage: quota update timeout missing?"
821              );
822            }
823          }, prefs.getIntPref("quotaUpdateDelay"));
824          this._updateQuotaTimeouts.add(timeoutID);
825        }
826        return this._decryptAndNotifyApp(record, messageID, headers, data);
827      })
828      .catch(error => {
829        console.error("receivedPushMessage: Error notifying app", error);
830        return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
831      });
832  },
833
834  /**
835   * Dispatches a broadcast notification to the BroadcastService.
836   *
837   * @param {Object} message The reply received by PushServiceWebSocket
838   * @param {Object} context Additional information about the context in which the
839   *  notification was received.
840   */
841  receivedBroadcastMessage(message, context) {
842    pushBroadcastService
843      .receivedBroadcastMessage(message.broadcasts, context)
844      .catch(e => {
845        console.error(e);
846      });
847  },
848
849  /**
850   * Updates a registration record after receiving a push message.
851   *
852   * @param {String} keyID The push registration ID.
853   * @param {Function} updateFunc The function passed to `receivedPushMessage`.
854   * @returns {Promise} Resolves with the updated record, or rejects if the
855   *  record was not updated.
856   */
857  _updateRecordAfterPush(keyID, updateFunc) {
858    return this.getByKeyID(keyID)
859      .then(record => {
860        if (!record) {
861          throw new Error("No record for key ID " + keyID);
862        }
863        return record
864          .getLastVisit()
865          .then(lastVisit => {
866            // As a special case, don't notify the service worker if the user
867            // cleared their history.
868            if (!isFinite(lastVisit)) {
869              throw new Error("Ignoring message sent to unvisited origin");
870            }
871            return lastVisit;
872          })
873          .then(lastVisit => {
874            // Update the record, resetting the quota if the user has visited the
875            // site since the last push.
876            return this._db.update(keyID, record => {
877              let newRecord = updateFunc(record);
878              if (!newRecord) {
879                return null;
880              }
881              // Because `unregister` is advisory only, we can still receive messages
882              // for stale Simple Push registrations from the server. To work around
883              // this, we check if the record has expired before *and* after updating
884              // the quota.
885              if (newRecord.isExpired()) {
886                return null;
887              }
888              newRecord.receivedPush(lastVisit);
889              return newRecord;
890            });
891          });
892      })
893      .then(record => {
894        gPushNotifier.notifySubscriptionModified(
895          record.scope,
896          record.principal
897        );
898        return record;
899      });
900  },
901
902  /**
903   * Decrypts an incoming message and notifies the associated service worker.
904   *
905   * @param {PushRecord} record The receiving registration.
906   * @param {String} messageID The message ID.
907   * @param {Object} headers The encryption headers.
908   * @param {ArrayBuffer|Uint8Array} data The encrypted message data.
909   * @returns {Promise} Resolves with an ack status code.
910   */
911  _decryptAndNotifyApp(record, messageID, headers, data) {
912    return PushCrypto.decrypt(
913      record.p256dhPrivateKey,
914      record.p256dhPublicKey,
915      record.authenticationSecret,
916      headers,
917      data
918    ).then(
919      message => this._notifyApp(record, messageID, message),
920      error => {
921        console.warn(
922          "decryptAndNotifyApp: Error decrypting message",
923          record.scope,
924          messageID,
925          error
926        );
927
928        let message = error.format(record.scope);
929        gPushNotifier.notifyError(
930          record.scope,
931          record.principal,
932          message,
933          Ci.nsIScriptError.errorFlag
934        );
935        return Ci.nsIPushErrorReporter.ACK_DECRYPTION_ERROR;
936      }
937    );
938  },
939
940  _updateQuota(keyID) {
941    console.debug("updateQuota()");
942
943    this._db
944      .update(keyID, record => {
945        // Record may have expired from an earlier quota update.
946        if (record.isExpired()) {
947          console.debug(
948            "updateQuota: Trying to update quota for expired record",
949            record
950          );
951          return null;
952        }
953        // If there are visible notifications, don't apply the quota penalty
954        // for the message.
955        if (record.uri && !this._visibleNotifications.has(record.uri.prePath)) {
956          record.reduceQuota();
957        }
958        return record;
959      })
960      .then(record => {
961        if (record.isExpired()) {
962          // Drop the registration in the background. If the user returns to the
963          // site, the service worker will be notified on the next `idle-daily`
964          // event.
965          this._backgroundUnregister(
966            record,
967            Ci.nsIPushErrorReporter.UNSUBSCRIBE_QUOTA_EXCEEDED
968          );
969        } else {
970          gPushNotifier.notifySubscriptionModified(
971            record.scope,
972            record.principal
973          );
974        }
975        if (this._updateQuotaTestCallback) {
976          // Callback so that test may be notified when the quota update is complete.
977          this._updateQuotaTestCallback();
978        }
979      })
980      .catch(error => {
981        console.debug("updateQuota: Error while trying to update quota", error);
982      });
983  },
984
985  notificationForOriginShown(origin) {
986    console.debug("notificationForOriginShown()", origin);
987    let count;
988    if (this._visibleNotifications.has(origin)) {
989      count = this._visibleNotifications.get(origin);
990    } else {
991      count = 0;
992    }
993    this._visibleNotifications.set(origin, count + 1);
994  },
995
996  notificationForOriginClosed(origin) {
997    console.debug("notificationForOriginClosed()", origin);
998    let count;
999    if (this._visibleNotifications.has(origin)) {
1000      count = this._visibleNotifications.get(origin);
1001    } else {
1002      console.debug(
1003        "notificationForOriginClosed: closing notification that has not been shown?"
1004      );
1005      return;
1006    }
1007    if (count > 1) {
1008      this._visibleNotifications.set(origin, count - 1);
1009    } else {
1010      this._visibleNotifications.delete(origin);
1011    }
1012  },
1013
1014  reportDeliveryError(messageID, reason) {
1015    console.debug("reportDeliveryError()", messageID, reason);
1016    if (this._state == PUSH_SERVICE_RUNNING && this._service.isConnected()) {
1017      // Only report errors if we're initialized and connected.
1018      this._service.reportDeliveryError(messageID, reason);
1019    }
1020  },
1021
1022  _notifyApp(aPushRecord, messageID, message) {
1023    if (
1024      !aPushRecord ||
1025      !aPushRecord.scope ||
1026      aPushRecord.originAttributes === undefined
1027    ) {
1028      console.error("notifyApp: Invalid record", aPushRecord);
1029      return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
1030    }
1031
1032    console.debug("notifyApp()", aPushRecord.scope);
1033
1034    // If permission has been revoked, trash the message.
1035    if (!aPushRecord.hasPermission()) {
1036      console.warn("notifyApp: Missing push permission", aPushRecord);
1037      return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
1038    }
1039
1040    let payload = ArrayBuffer.isView(message)
1041      ? new Uint8Array(message.buffer)
1042      : message;
1043
1044    if (aPushRecord.quotaApplies()) {
1045      // Don't record telemetry for chrome push messages.
1046      Services.telemetry.getHistogramById("PUSH_API_NOTIFY").add();
1047    }
1048
1049    if (payload) {
1050      gPushNotifier.notifyPushWithData(
1051        aPushRecord.scope,
1052        aPushRecord.principal,
1053        messageID,
1054        payload
1055      );
1056    } else {
1057      gPushNotifier.notifyPush(
1058        aPushRecord.scope,
1059        aPushRecord.principal,
1060        messageID
1061      );
1062    }
1063
1064    return Ci.nsIPushErrorReporter.ACK_DELIVERED;
1065  },
1066
1067  getByKeyID(aKeyID) {
1068    return this._db.getByKeyID(aKeyID);
1069  },
1070
1071  getAllUnexpired() {
1072    return this._db.getAllUnexpired();
1073  },
1074
1075  _sendRequest(action, ...params) {
1076    if (this._state == PUSH_SERVICE_CONNECTION_DISABLE) {
1077      return Promise.reject(new Error("Push service disabled"));
1078    }
1079    if (this._state == PUSH_SERVICE_ACTIVE_OFFLINE) {
1080      return Promise.reject(new Error("Push service offline"));
1081    }
1082    // Ensure the backend is ready. `getByPageRecord` already checks this, but
1083    // we need to check again here in case the service was restarted in the
1084    // meantime.
1085    return this._checkActivated().then(_ => {
1086      switch (action) {
1087        case "register":
1088          return this._service.register(...params);
1089        case "unregister":
1090          return this._service.unregister(...params);
1091      }
1092      return Promise.reject(new Error("Unknown request type: " + action));
1093    });
1094  },
1095
1096  /**
1097   * Called on message from the child process. aPageRecord is an object sent by
1098   * the push manager, identifying the sending page and other fields.
1099   */
1100  _registerWithServer(aPageRecord) {
1101    console.debug("registerWithServer()", aPageRecord);
1102
1103    return this._sendRequest("register", aPageRecord)
1104      .then(
1105        record => this._onRegisterSuccess(record),
1106        err => this._onRegisterError(err)
1107      )
1108      .then(
1109        record => {
1110          this._deletePendingRequest(aPageRecord);
1111          gPushNotifier.notifySubscriptionModified(
1112            record.scope,
1113            record.principal
1114          );
1115          return record.toSubscription();
1116        },
1117        err => {
1118          this._deletePendingRequest(aPageRecord);
1119          throw err;
1120        }
1121      );
1122  },
1123
1124  _sendUnregister(aRecord, aReason) {
1125    return this._sendRequest("unregister", aRecord, aReason);
1126  },
1127
1128  /**
1129   * Exceptions thrown in _onRegisterSuccess are caught by the promise obtained
1130   * from _service.request, causing the promise to be rejected instead.
1131   */
1132  _onRegisterSuccess(aRecord) {
1133    console.debug("_onRegisterSuccess()");
1134
1135    return this._db.put(aRecord).catch(error => {
1136      // Unable to save. Destroy the subscription in the background.
1137      this._backgroundUnregister(
1138        aRecord,
1139        Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL
1140      );
1141      throw error;
1142    });
1143  },
1144
1145  /**
1146   * Exceptions thrown in _onRegisterError are caught by the promise obtained
1147   * from _service.request, causing the promise to be rejected instead.
1148   */
1149  _onRegisterError(reply) {
1150    console.debug("_onRegisterError()");
1151
1152    if (!reply.error) {
1153      console.warn(
1154        "onRegisterError: Called without valid error message!",
1155        reply
1156      );
1157      throw new Error("Registration error");
1158    }
1159    throw reply.error;
1160  },
1161
1162  notificationsCleared() {
1163    this._visibleNotifications.clear();
1164  },
1165
1166  _getByPageRecord(pageRecord) {
1167    return this._checkActivated().then(_ =>
1168      this._db.getByIdentifiers(pageRecord)
1169    );
1170  },
1171
1172  register(aPageRecord) {
1173    console.debug("register()", aPageRecord);
1174
1175    let keyPromise;
1176    if (aPageRecord.appServerKey && aPageRecord.appServerKey.length != 0) {
1177      let keyView = new Uint8Array(aPageRecord.appServerKey);
1178      keyPromise = PushCrypto.validateAppServerKey(keyView).catch(error => {
1179        // Normalize Web Crypto exceptions. `nsIPushService` will forward the
1180        // error result to the DOM API implementation in `PushManager.cpp` or
1181        // `Push.js`, which will convert it to the correct `DOMException`.
1182        throw errorWithResult(
1183          "Invalid app server key",
1184          Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR
1185        );
1186      });
1187    } else {
1188      keyPromise = Promise.resolve(null);
1189    }
1190
1191    return Promise.all([keyPromise, this._getByPageRecord(aPageRecord)]).then(
1192      ([appServerKey, record]) => {
1193        aPageRecord.appServerKey = appServerKey;
1194        if (!record) {
1195          return this._lookupOrPutPendingRequest(aPageRecord);
1196        }
1197        if (!record.matchesAppServerKey(appServerKey)) {
1198          throw errorWithResult(
1199            "Mismatched app server key",
1200            Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR
1201          );
1202        }
1203        if (record.isExpired()) {
1204          return record
1205            .quotaChanged()
1206            .then(isChanged => {
1207              if (isChanged) {
1208                // If the user revisited the site, drop the expired push
1209                // registration and re-register.
1210                return this.dropRegistrationAndNotifyApp(record.keyID);
1211              }
1212              throw new Error("Push subscription expired");
1213            })
1214            .then(_ => this._lookupOrPutPendingRequest(aPageRecord));
1215        }
1216        return record.toSubscription();
1217      }
1218    );
1219  },
1220
1221  /*
1222   * Called only by the PushBroadcastService on the receipt of a new
1223   * subscription. Don't call this directly. Go through PushBroadcastService.
1224   */
1225  async subscribeBroadcast(broadcastId, version) {
1226    if (this._state != PUSH_SERVICE_RUNNING) {
1227      // Ignore any request to subscribe before we send a hello.
1228      // We'll send all the broadcast listeners as part of the hello
1229      // anyhow.
1230      return;
1231    }
1232
1233    await this._service.sendSubscribeBroadcast(broadcastId, version);
1234  },
1235
1236  /**
1237   * Called on message from the child process.
1238   *
1239   * Why is the record being deleted from the local database before the server
1240   * is told?
1241   *
1242   * Unregistration is for the benefit of the app and the AppServer
1243   * so that the AppServer does not keep pinging a channel the UserAgent isn't
1244   * watching The important part of the transaction in this case is left to the
1245   * app, to tell its server of the unregistration.  Even if the request to the
1246   * PushServer were to fail, it would not affect correctness of the protocol,
1247   * and the server GC would just clean up the channelID/subscription
1248   * eventually.  Since the appserver doesn't ping it, no data is lost.
1249   *
1250   * If rather we were to unregister at the server and update the database only
1251   * on success: If the server receives the unregister, and deletes the
1252   * channelID/subscription, but the response is lost because of network
1253   * failure, the application is never informed. In addition the application may
1254   * retry the unregister when it fails due to timeout (websocket) or any other
1255   * reason at which point the server will say it does not know of this
1256   * unregistration.  We'll have to make the registration/unregistration phases
1257   * have retries and attempts to resend messages from the server, and have the
1258   * client acknowledge. On a server, data is cheap, reliable notification is
1259   * not.
1260   */
1261  unregister(aPageRecord) {
1262    console.debug("unregister()", aPageRecord);
1263
1264    return this._getByPageRecord(aPageRecord).then(record => {
1265      if (record === null) {
1266        return false;
1267      }
1268
1269      let reason = Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL;
1270      return Promise.all([
1271        this._sendUnregister(record, reason),
1272        this._db.delete(record.keyID).then(rec => {
1273          if (rec) {
1274            gPushNotifier.notifySubscriptionModified(rec.scope, rec.principal);
1275          }
1276        }),
1277      ]).then(([success]) => success);
1278    });
1279  },
1280
1281  clear(info) {
1282    return this._checkActivated()
1283      .then(_ => {
1284        return this._dropRegistrationsIf(
1285          record =>
1286            info.domain == "*" ||
1287            (record.uri &&
1288              eTLDService.hasRootDomain(record.uri.prePath, info.domain))
1289        );
1290      })
1291      .catch(e => {
1292        console.warn(
1293          "clear: Error dropping subscriptions for domain",
1294          info.domain,
1295          e
1296        );
1297        return Promise.resolve();
1298      });
1299  },
1300
1301  registration(aPageRecord) {
1302    console.debug("registration()");
1303
1304    return this._getByPageRecord(aPageRecord).then(record => {
1305      if (!record) {
1306        return null;
1307      }
1308      if (record.isExpired()) {
1309        return record.quotaChanged().then(isChanged => {
1310          if (isChanged) {
1311            return this.dropRegistrationAndNotifyApp(record.keyID).then(
1312              _ => null
1313            );
1314          }
1315          return null;
1316        });
1317      }
1318      return record.toSubscription();
1319    });
1320  },
1321
1322  _dropExpiredRegistrations() {
1323    console.debug("dropExpiredRegistrations()");
1324
1325    return this._db.getAllExpired().then(records => {
1326      return Promise.all(
1327        records.map(record =>
1328          record
1329            .quotaChanged()
1330            .then(isChanged => {
1331              if (isChanged) {
1332                // If the user revisited the site, drop the expired push
1333                // registration and notify the associated service worker.
1334                this.dropRegistrationAndNotifyApp(record.keyID);
1335              }
1336            })
1337            .catch(error => {
1338              console.error(
1339                "dropExpiredRegistrations: Error dropping registration",
1340                record.keyID,
1341                error
1342              );
1343            })
1344        )
1345      );
1346    });
1347  },
1348
1349  _onPermissionChange(subject, data) {
1350    console.debug("onPermissionChange()");
1351
1352    if (data == "cleared") {
1353      return this._clearPermissions();
1354    }
1355
1356    let permission = subject.QueryInterface(Ci.nsIPermission);
1357    if (permission.type != "desktop-notification") {
1358      return Promise.resolve();
1359    }
1360
1361    return this._updatePermission(permission, data);
1362  },
1363
1364  _clearPermissions() {
1365    console.debug("clearPermissions()");
1366
1367    return this._db.clearIf(record => {
1368      if (!record.quotaApplies()) {
1369        // Only drop registrations that are subject to quota.
1370        return false;
1371      }
1372      this._backgroundUnregister(
1373        record,
1374        Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED
1375      );
1376      return true;
1377    });
1378  },
1379
1380  _updatePermission(permission, type) {
1381    console.debug("updatePermission()");
1382
1383    let isAllow = permission.capability == Ci.nsIPermissionManager.ALLOW_ACTION;
1384    let isChange = type == "added" || type == "changed";
1385
1386    if (isAllow && isChange) {
1387      // Permission set to "allow". Drop all expired registrations for this
1388      // site, notify the associated service workers, and reset the quota
1389      // for active registrations.
1390      return this._forEachPrincipal(permission.principal, (record, cursor) =>
1391        this._permissionAllowed(record, cursor)
1392      );
1393    } else if (isChange || (isAllow && type == "deleted")) {
1394      // Permission set to "block" or "always ask," or "allow" permission
1395      // removed. Expire all registrations for this site.
1396      return this._forEachPrincipal(permission.principal, (record, cursor) =>
1397        this._permissionDenied(record, cursor)
1398      );
1399    }
1400
1401    return Promise.resolve();
1402  },
1403
1404  _forEachPrincipal(principal, callback) {
1405    return this._db.forEachOrigin(
1406      principal.URI.prePath,
1407      ChromeUtils.originAttributesToSuffix(principal.originAttributes),
1408      callback
1409    );
1410  },
1411
1412  /**
1413   * The update function called for each registration record if the push
1414   * permission is revoked. We only expire the record so we can notify the
1415   * service worker as soon as the permission is reinstated. If we just
1416   * deleted the record, the worker wouldn't be notified until the next visit
1417   * to the site.
1418   *
1419   * @param {PushRecord} record The record to expire.
1420   * @param {IDBCursor} cursor The IndexedDB cursor.
1421   */
1422  _permissionDenied(record, cursor) {
1423    console.debug("permissionDenied()");
1424
1425    if (!record.quotaApplies() || record.isExpired()) {
1426      // Ignore already-expired records.
1427      return;
1428    }
1429    // Drop the registration in the background.
1430    this._backgroundUnregister(
1431      record,
1432      Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED
1433    );
1434    record.setQuota(0);
1435    cursor.update(record);
1436  },
1437
1438  /**
1439   * The update function called for each registration record if the push
1440   * permission is granted. If the record has expired, it will be dropped;
1441   * otherwise, its quota will be reset to the default value.
1442   *
1443   * @param {PushRecord} record The record to update.
1444   * @param {IDBCursor} cursor The IndexedDB cursor.
1445   */
1446  _permissionAllowed(record, cursor) {
1447    console.debug("permissionAllowed()");
1448
1449    if (!record.quotaApplies()) {
1450      return;
1451    }
1452    if (record.isExpired()) {
1453      // If the registration has expired, drop and notify the worker
1454      // unconditionally.
1455      this._notifySubscriptionChangeObservers(record);
1456      cursor.delete();
1457      return;
1458    }
1459    record.resetQuota();
1460    cursor.update(record);
1461  },
1462
1463  /**
1464   * Drops all matching registrations from the database. Notifies the
1465   * associated service workers if permission is granted, and removes
1466   * unexpired registrations from the server.
1467   *
1468   * @param {Function} predicate A function called for each record.
1469   * @returns {Promise} Resolves once the registrations have been dropped.
1470   */
1471  _dropRegistrationsIf(predicate) {
1472    return this._db.clearIf(record => {
1473      if (!predicate(record)) {
1474        return false;
1475      }
1476      if (record.hasPermission()) {
1477        // "Clear Recent History" and the Forget button remove permissions
1478        // before clearing registrations, but it's possible for the worker to
1479        // resubscribe if the "dom.push.testing.ignorePermission" pref is set.
1480        this._notifySubscriptionChangeObservers(record);
1481      }
1482      if (!record.isExpired()) {
1483        // Only unregister active registrations, since we already told the
1484        // server about expired ones.
1485        this._backgroundUnregister(
1486          record,
1487          Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL
1488        );
1489      }
1490      return true;
1491    });
1492  },
1493};
1494