Bug 1685822 [wpt PR 27117] - [Import Maps] Add tests for rejecting multiple import...
[gecko.git] / dom / push / PushService.jsm
blob09dc2aaf413daa83341d6faaa51cdd2edb5018f0
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/. */
5 "use strict";
7 const { AppConstants } = ChromeUtils.import(
8   "resource://gre/modules/AppConstants.jsm"
9 );
10 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
11 const { clearTimeout, setTimeout } = ChromeUtils.import(
12   "resource://gre/modules/Timer.jsm"
14 const { XPCOMUtils } = ChromeUtils.import(
15   "resource://gre/modules/XPCOMUtils.jsm"
18 var PushServiceWebSocket, PushServiceHttp2;
20 XPCOMUtils.defineLazyServiceGetter(
21   this,
22   "gPushNotifier",
23   "@mozilla.org/push/Notifier;1",
24   "nsIPushNotifier"
26 XPCOMUtils.defineLazyServiceGetter(
27   this,
28   "eTLDService",
29   "@mozilla.org/network/effective-tld-service;1",
30   "nsIEffectiveTLDService"
32 ChromeUtils.defineModuleGetter(
33   this,
34   "pushBroadcastService",
35   "resource://gre/modules/PushBroadcastService.jsm"
37 ChromeUtils.defineModuleGetter(
38   this,
39   "PushCrypto",
40   "resource://gre/modules/PushCrypto.jsm"
42 ChromeUtils.defineModuleGetter(
43   this,
44   "PushServiceAndroidGCM",
45   "resource://gre/modules/PushServiceAndroidGCM.jsm"
48 const 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 })();
61 const EXPORTED_SYMBOLS = ["PushService"];
63 XPCOMUtils.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 });
71 const prefs = Services.prefs.getBranch("dom.push.");
73 const PUSH_SERVICE_UNINIT = 0;
74 const PUSH_SERVICE_INIT = 1; // No serverURI
75 const PUSH_SERVICE_ACTIVATING = 2; // activating db
76 const PUSH_SERVICE_CONNECTION_DISABLE = 3;
77 const PUSH_SERVICE_ACTIVE_OFFLINE = 4;
78 const PUSH_SERVICE_RUNNING = 5;
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  **/
96 // This is for starting and stopping service.
97 const STARTING_SERVICE_EVENT = 0;
98 const CHANGING_SERVICE_EVENT = 1;
99 const STOPPING_SERVICE_EVENT = 2;
100 const UNINIT_EVENT = 3;
102 // Returns the backend for the given server URI.
103 function 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;
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  */
129 function errorWithResult(message, result = Cr.NS_ERROR_FAILURE) {
130   let error = new Error(message);
131   error.result = result;
132   return error;
136  * The implementation of the push system. It uses WebSockets
137  * (PushServiceWebSocket) to communicate with the server and PushDB (IndexedDB)
138  * for persistence.
139  */
140 var PushService = {
141   _service: null,
142   _state: PUSH_SERVICE_UNINIT,
143   _db: null,
144   _options: null,
145   _visibleNotifications: new Map(),
147   // Callback that is called after attempting to
148   // reduce the quota for a record. Used for testing purposes.
149   _updateQuotaTestCallback: null,
151   // Set of timeout ID of tasks to reduce quota.
152   _updateQuotaTimeouts: new Set(),
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     }
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   },
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   },
201   _makePendingKey(aPageRecord) {
202     return aPageRecord.scope + "|" + aPageRecord.originAttributes;
203   },
205   _lookupOrPutPendingRequest(aPageRecord) {
206     let key = this._makePendingKey(aPageRecord);
207     if (this._pendingRegisterRequest[key]) {
208       return this._pendingRegisterRequest[key];
209     }
211     return (this._pendingRegisterRequest[key] = this._registerWithServer(
212       aPageRecord
213     ));
214   },
216   _deletePendingRequest(aPageRecord) {
217     let key = this._makePendingKey(aPageRecord);
218     if (this._pendingRegisterRequest[key]) {
219       delete this._pendingRegisterRequest[key];
220     }
221   },
223   _setState(aNewState) {
224     console.debug(
225       "setState()",
226       "new state",
227       aNewState,
228       "old state",
229       this._state
230     );
232     if (this._state == aNewState) {
233       return;
234     }
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   },
252   async _changeStateOfflineEvent(offline, calledFromConnEnabledEvent) {
253     console.debug("changeStateOfflineEvent()", offline);
255     if (
256       this._state < PUSH_SERVICE_ACTIVE_OFFLINE &&
257       this._state != PUSH_SERVICE_ACTIVATING &&
258       !calledFromConnEnabledEvent
259     ) {
260       return;
261     }
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     }
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     }
278     let broadcastListeners = await pushBroadcastService.getListeners();
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);
290     this._service.connect(broadcastListeners);
291   },
293   _changeStateConnectionEnabledEvent(enabled) {
294     console.debug("changeStateConnectionEnabledEvent()", enabled);
296     if (
297       this._state < PUSH_SERVICE_CONNECTION_DISABLE &&
298       this._state != PUSH_SERVICE_ACTIVATING
299     ) {
300       return Promise.resolve();
301     }
303     if (enabled) {
304       return this._changeStateOfflineEvent(Services.io.offline, true);
305     }
307     if (this._state == PUSH_SERVICE_RUNNING) {
308       this._service.disconnect();
309     }
310     this._setState(PUSH_SERVICE_CONNECTION_DISABLE);
311     return Promise.resolve();
312   },
314   // Used for testing.
315   changeTestServer(url, options = {}) {
316     console.debug("changeTestServer()");
318     return this._stateChangeProcessEnqueue(_ => {
319       if (this._state < PUSH_SERVICE_ACTIVATING) {
320         console.debug("changeTestServer: PushService not activated?");
321         return Promise.resolve();
322       }
324       return this._changeServerURL(url, CHANGING_SERVICE_EVENT, options);
325     });
326   },
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;
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;
365       case "idle-daily":
366         this._dropExpiredRegistrations().catch(error => {
367           console.error("Failed to drop expired registrations on idle", error);
368         });
369         break;
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;
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   },
388   _clearOriginData(data) {
389     console.log("clearOriginData()");
391     if (!data) {
392       return Promise.resolve();
393     }
395     let pattern = JSON.parse(data);
396     return this._dropRegistrationsIf(record =>
397       record.matchesOriginAttributes(pattern)
398     );
399   },
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()");
412     if (!this._service.isConnected() || !record) {
413       return;
414     }
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   },
429   _findService(serverURL) {
430     console.debug("findService()");
432     if (!serverURL) {
433       console.warn("findService: No dom.push.serverURL found");
434       return [];
435     }
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     }
449     let service = getServiceForServerURI(uri);
450     return [service, uri];
451   },
453   _changeServerURL(serverURI, event, options = {}) {
454     console.debug("changeServerURL()");
456     switch (event) {
457       case UNINIT_EVENT:
458         return this._stopService(event);
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);
503       default:
504         console.error("Unexpected event in _changeServerURL", event);
505         return Promise.reject(new Error(`Unexpected event ${event}`));
506     }
507   },
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()");
527     if (this._state > PUSH_SERVICE_UNINIT) {
528       return;
529     }
531     this._setState(PUSH_SERVICE_ACTIVATING);
533     prefs.addObserver("serverURL", this);
534     Services.obs.addObserver(this, "quit-application");
536     if (options.serverURI) {
537       // this is use for xpcshell test.
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   },
558   _startObservers() {
559     console.debug("startObservers()");
561     if (this._state != PUSH_SERVICE_ACTIVATING) {
562       return;
563     }
565     Services.obs.addObserver(this, "clear-origin-attributes-data");
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");
572     // Used to monitor if the user wishes to disable Push.
573     prefs.addObserver("connection.enabled", this);
575     // Prunes expired registrations and notifies dormant service workers.
576     Services.obs.addObserver(this, "idle-daily");
578     // Prunes registrations for sites for which the user revokes push
579     // permissions.
580     Services.obs.addObserver(this, "perm-changed");
581   },
583   _startService(service, serverURI, options) {
584     console.debug("startService()");
586     if (this._state != PUSH_SERVICE_ACTIVATING) {
587       return Promise.reject();
588     }
590     this._service = service;
592     this._db = options.db;
593     if (!this._db) {
594       this._db = this._service.newPushDB();
595     }
597     return this._service.init(options, this, serverURI).then(() => {
598       this._startObservers();
599       return this._dropExpiredRegistrations();
600     });
601   },
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()");
615     if (this._state < PUSH_SERVICE_ACTIVATING) {
616       return Promise.resolve();
617     }
619     this._stopObservers();
621     this._service.disconnect();
622     this._service.uninit();
623     this._service = null;
625     this._updateQuotaTimeouts.forEach(timeoutID => clearTimeout(timeoutID));
626     this._updateQuotaTimeouts.clear();
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     }
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   },
650   _stopObservers() {
651     console.debug("stopObservers()");
653     if (this._state < PUSH_SERVICE_ACTIVATING) {
654       return;
655     }
657     prefs.removeObserver("connection.enabled", this);
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   },
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   },
672   async uninit() {
673     console.debug("uninit()");
675     if (this._state == PUSH_SERVICE_UNINIT) {
676       return;
677     }
679     prefs.removeObserver("serverURL", this);
680     Services.obs.removeObserver(this, "quit-application");
682     await this._stateChangeProcessEnqueue(_ => this._shutdownService());
683   },
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   },
706   _notifySubscriptionChangeObservers(record) {
707     if (!record) {
708       return;
709     }
710     gPushNotifier.notifySubscriptionChange(record.scope, record.principal);
711   },
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   },
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   },
752   ensureCrypto(record) {
753     if (
754       record.hasAuthenticationSecret() &&
755       record.p256dhPublicKey &&
756       record.p256dhPrivateKey
757     ) {
758       return Promise.resolve(record);
759     }
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   },
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()");
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   },
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   },
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   },
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         );
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   },
940   _updateQuota(keyID) {
941     console.debug("updateQuota()");
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   },
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   },
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   },
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   },
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     }
1032     console.debug("notifyApp()", aPushRecord.scope);
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     }
1040     let payload = ArrayBuffer.isView(message)
1041       ? new Uint8Array(message.buffer)
1042       : message;
1044     if (aPushRecord.quotaApplies()) {
1045       // Don't record telemetry for chrome push messages.
1046       Services.telemetry.getHistogramById("PUSH_API_NOTIFY").add();
1047     }
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     }
1064     return Ci.nsIPushErrorReporter.ACK_DELIVERED;
1065   },
1067   getByKeyID(aKeyID) {
1068     return this._db.getByKeyID(aKeyID);
1069   },
1071   getAllUnexpired() {
1072     return this._db.getAllUnexpired();
1073   },
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   },
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);
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   },
1124   _sendUnregister(aRecord, aReason) {
1125     return this._sendRequest("unregister", aRecord, aReason);
1126   },
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()");
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   },
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()");
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   },
1162   notificationsCleared() {
1163     this._visibleNotifications.clear();
1164   },
1166   _getByPageRecord(pageRecord) {
1167     return this._checkActivated().then(_ =>
1168       this._db.getByIdentifiers(pageRecord)
1169     );
1170   },
1172   register(aPageRecord) {
1173     console.debug("register()", aPageRecord);
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     }
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   },
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     }
1233     await this._service.sendSubscribeBroadcast(broadcastId, version);
1234   },
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);
1264     return this._getByPageRecord(aPageRecord).then(record => {
1265       if (record === null) {
1266         return false;
1267       }
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   },
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   },
1301   registration(aPageRecord) {
1302     console.debug("registration()");
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   },
1322   _dropExpiredRegistrations() {
1323     console.debug("dropExpiredRegistrations()");
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   },
1349   _onPermissionChange(subject, data) {
1350     console.debug("onPermissionChange()");
1352     if (data == "cleared") {
1353       return this._clearPermissions();
1354     }
1356     let permission = subject.QueryInterface(Ci.nsIPermission);
1357     if (permission.type != "desktop-notification") {
1358       return Promise.resolve();
1359     }
1361     return this._updatePermission(permission, data);
1362   },
1364   _clearPermissions() {
1365     console.debug("clearPermissions()");
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   },
1380   _updatePermission(permission, type) {
1381     console.debug("updatePermission()");
1383     let isAllow = permission.capability == Ci.nsIPermissionManager.ALLOW_ACTION;
1384     let isChange = type == "added" || type == "changed";
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     }
1401     return Promise.resolve();
1402   },
1404   _forEachPrincipal(principal, callback) {
1405     return this._db.forEachOrigin(
1406       principal.URI.prePath,
1407       ChromeUtils.originAttributesToSuffix(principal.originAttributes),
1408       callback
1409     );
1410   },
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()");
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   },
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()");
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   },
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   },