diff options
Diffstat (limited to 'dom/push/PushService.jsm')
-rw-r--r-- | dom/push/PushService.jsm | 1365 |
1 files changed, 1365 insertions, 0 deletions
diff --git a/dom/push/PushService.jsm b/dom/push/PushService.jsm new file mode 100644 index 000000000..373807024 --- /dev/null +++ b/dom/push/PushService.jsm @@ -0,0 +1,1365 @@ +/* jshint moz: true, esnext: true */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +const { + PushCrypto, + getCryptoParams, + CryptoError, +} = Cu.import("resource://gre/modules/PushCrypto.jsm"); +const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm"); + +const CONNECTION_PROTOCOLS = (function() { + if ('android' != AppConstants.MOZ_WIDGET_TOOLKIT) { + const {PushServiceWebSocket} = Cu.import("resource://gre/modules/PushServiceWebSocket.jsm"); + const {PushServiceHttp2} = Cu.import("resource://gre/modules/PushServiceHttp2.jsm"); + return [PushServiceWebSocket, PushServiceHttp2]; + } else { + const {PushServiceAndroidGCM} = Cu.import("resource://gre/modules/PushServiceAndroidGCM.jsm"); + return [PushServiceAndroidGCM]; + } +})(); + +XPCOMUtils.defineLazyServiceGetter(this, "gPushNotifier", + "@mozilla.org/push/Notifier;1", + "nsIPushNotifier"); + +this.EXPORTED_SYMBOLS = ["PushService"]; + +XPCOMUtils.defineLazyGetter(this, "console", () => { + let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {}); + return new ConsoleAPI({ + maxLogLevelPref: "dom.push.loglevel", + prefix: "PushService", + }); +}); + +const prefs = new Preferences("dom.push."); + +const PUSH_SERVICE_UNINIT = 0; +const PUSH_SERVICE_INIT = 1; // No serverURI +const PUSH_SERVICE_ACTIVATING = 2;//activating db +const PUSH_SERVICE_CONNECTION_DISABLE = 3; +const PUSH_SERVICE_ACTIVE_OFFLINE = 4; +const PUSH_SERVICE_RUNNING = 5; + +// Telemetry failure to send push notification to Service Worker reasons. +// Key not found in local database. +const kDROP_NOTIFICATION_REASON_KEY_NOT_FOUND = 0; +// User cleared history. +const kDROP_NOTIFICATION_REASON_NO_HISTORY = 1; +// Version of message received not newer than previous one. +const kDROP_NOTIFICATION_REASON_NO_VERSION_INCREMENT = 2; +// Subscription has expired. +const kDROP_NOTIFICATION_REASON_EXPIRED = 3; + +/** + * State is change only in couple of functions: + * init - change state to PUSH_SERVICE_INIT if state was PUSH_SERVICE_UNINIT + * changeServerURL - change state to PUSH_SERVICE_ACTIVATING if serverURL + * present or PUSH_SERVICE_INIT if not present. + * changeStateConnectionEnabledEvent - it is call on pref change or during + * the service activation and it can + * change state to + * PUSH_SERVICE_CONNECTION_DISABLE + * changeStateOfflineEvent - it is called when offline state changes or during + * the service activation and it change state to + * PUSH_SERVICE_ACTIVE_OFFLINE or + * PUSH_SERVICE_RUNNING. + * uninit - change state to PUSH_SERVICE_UNINIT. + **/ + +// This is for starting and stopping service. +const STARTING_SERVICE_EVENT = 0; +const CHANGING_SERVICE_EVENT = 1; +const STOPPING_SERVICE_EVENT = 2; +const UNINIT_EVENT = 3; + +/** + * Annotates an error with an XPCOM result code. We use this helper + * instead of `Components.Exception` because the latter can assert in + * `nsXPCComponents_Exception::HasInstance` when inspected at shutdown. + */ +function errorWithResult(message, result = Cr.NS_ERROR_FAILURE) { + let error = new Error(message); + error.result = result; + return error; +} + +/** + * Copied from ForgetAboutSite.jsm. + * + * Returns true if the string passed in is part of the root domain of the + * current string. For example, if this is "www.mozilla.org", and we pass in + * "mozilla.org", this will return true. It would return false the other way + * around. + */ +function hasRootDomain(str, aDomain) +{ + let index = str.indexOf(aDomain); + // If aDomain is not found, we know we do not have it as a root domain. + if (index == -1) + return false; + + // If the strings are the same, we obviously have a match. + if (str == aDomain) + return true; + + // Otherwise, we have aDomain as our root domain iff the index of aDomain is + // aDomain.length subtracted from our length and (since we do not have an + // exact match) the character before the index is a dot or slash. + let prevChar = str[index - 1]; + return (index == (str.length - aDomain.length)) && + (prevChar == "." || prevChar == "/"); +} + +/** + * The implementation of the push system. It uses WebSockets + * (PushServiceWebSocket) to communicate with the server and PushDB (IndexedDB) + * for persistence. + */ +this.PushService = { + _service: null, + _state: PUSH_SERVICE_UNINIT, + _db: null, + _options: null, + _visibleNotifications: new Map(), + + // Callback that is called after attempting to + // reduce the quota for a record. Used for testing purposes. + _updateQuotaTestCallback: null, + + // Set of timeout ID of tasks to reduce quota. + _updateQuotaTimeouts: new Set(), + + // When serverURI changes (this is used for testing), db is cleaned up and a + // a new db is started. This events must be sequential. + _stateChangeProcessQueue: null, + _stateChangeProcessEnqueue: function(op) { + if (!this._stateChangeProcessQueue) { + this._stateChangeProcessQueue = Promise.resolve(); + } + + this._stateChangeProcessQueue = this._stateChangeProcessQueue + .then(op) + .catch(error => { + console.error( + "stateChangeProcessEnqueue: Error transitioning state", error); + return this._shutdownService(); + }) + .catch(error => { + console.error( + "stateChangeProcessEnqueue: Error shutting down service", error); + }); + return this._stateChangeProcessQueue; + }, + + // Pending request. If a worker try to register for the same scope again, do + // not send a new registration request. Therefore we need queue of pending + // register requests. This is the list of scopes which pending registration. + _pendingRegisterRequest: {}, + _notifyActivated: null, + _activated: null, + _checkActivated: function() { + if (this._state < PUSH_SERVICE_ACTIVATING) { + return Promise.reject(new Error("Push service not active")); + } else if (this._state > PUSH_SERVICE_ACTIVATING) { + return Promise.resolve(); + } else { + return (this._activated) ? this._activated : + this._activated = new Promise((res, rej) => + this._notifyActivated = {resolve: res, + reject: rej}); + } + }, + + _makePendingKey: function(aPageRecord) { + return aPageRecord.scope + "|" + aPageRecord.originAttributes; + }, + + _lookupOrPutPendingRequest: function(aPageRecord) { + let key = this._makePendingKey(aPageRecord); + if (this._pendingRegisterRequest[key]) { + return this._pendingRegisterRequest[key]; + } + + return this._pendingRegisterRequest[key] = this._registerWithServer(aPageRecord); + }, + + _deletePendingRequest: function(aPageRecord) { + let key = this._makePendingKey(aPageRecord); + if (this._pendingRegisterRequest[key]) { + delete this._pendingRegisterRequest[key]; + } + }, + + _setState: function(aNewState) { + console.debug("setState()", "new state", aNewState, "old state", this._state); + + if (this._state == aNewState) { + return; + } + + if (this._state == PUSH_SERVICE_ACTIVATING) { + // It is not important what is the new state as soon as we leave + // PUSH_SERVICE_ACTIVATING + if (this._notifyActivated) { + if (aNewState < PUSH_SERVICE_ACTIVATING) { + this._notifyActivated.reject(new Error("Push service not active")); + } else { + this._notifyActivated.resolve(); + } + } + this._notifyActivated = null; + this._activated = null; + } + this._state = aNewState; + }, + + _changeStateOfflineEvent: function(offline, calledFromConnEnabledEvent) { + console.debug("changeStateOfflineEvent()", offline); + + if (this._state < PUSH_SERVICE_ACTIVE_OFFLINE && + this._state != PUSH_SERVICE_ACTIVATING && + !calledFromConnEnabledEvent) { + return Promise.resolve(); + } + + if (offline) { + if (this._state == PUSH_SERVICE_RUNNING) { + this._service.disconnect(); + } + this._setState(PUSH_SERVICE_ACTIVE_OFFLINE); + return Promise.resolve(); + } + + if (this._state == PUSH_SERVICE_RUNNING) { + // PushService was not in the offline state, but got notification to + // go online (a offline notification has not been sent). + // Disconnect first. + this._service.disconnect(); + } + return this.getAllUnexpired().then(records => { + this._setState(PUSH_SERVICE_RUNNING); + if (records.length > 0) { + // if there are request waiting + this._service.connect(records); + } + }); + }, + + _changeStateConnectionEnabledEvent: function(enabled) { + console.debug("changeStateConnectionEnabledEvent()", enabled); + + if (this._state < PUSH_SERVICE_CONNECTION_DISABLE && + this._state != PUSH_SERVICE_ACTIVATING) { + return Promise.resolve(); + } + + if (enabled) { + return this._changeStateOfflineEvent(Services.io.offline, true); + } + + if (this._state == PUSH_SERVICE_RUNNING) { + this._service.disconnect(); + } + this._setState(PUSH_SERVICE_CONNECTION_DISABLE); + return Promise.resolve(); + }, + + // Used for testing. + changeTestServer(url, options = {}) { + console.debug("changeTestServer()"); + + return this._stateChangeProcessEnqueue(_ => { + if (this._state < PUSH_SERVICE_ACTIVATING) { + console.debug("changeTestServer: PushService not activated?"); + return Promise.resolve(); + } + + return this._changeServerURL(url, CHANGING_SERVICE_EVENT, options); + }); + }, + + observe: function observe(aSubject, aTopic, aData) { + switch (aTopic) { + /* + * We need to call uninit() on shutdown to clean up things that modules + * aren't very good at automatically cleaning up, so we don't get shutdown + * leaks on browser shutdown. + */ + case "quit-application": + this.uninit(); + break; + case "network:offline-status-changed": + this._stateChangeProcessEnqueue(_ => + this._changeStateOfflineEvent(aData === "offline", false) + ); + break; + + case "nsPref:changed": + if (aData == "dom.push.serverURL") { + console.debug("observe: dom.push.serverURL changed for websocket", + prefs.get("serverURL")); + this._stateChangeProcessEnqueue(_ => + this._changeServerURL(prefs.get("serverURL"), + CHANGING_SERVICE_EVENT) + ); + + } else if (aData == "dom.push.connection.enabled") { + this._stateChangeProcessEnqueue(_ => + this._changeStateConnectionEnabledEvent(prefs.get("connection.enabled")) + ); + } + break; + + case "idle-daily": + this._dropExpiredRegistrations().catch(error => { + console.error("Failed to drop expired registrations on idle", error); + }); + break; + + case "perm-changed": + this._onPermissionChange(aSubject, aData).catch(error => { + console.error("onPermissionChange: Error updating registrations:", + error); + }) + break; + + case "clear-origin-attributes-data": + this._clearOriginData(aData).catch(error => { + console.error("clearOriginData: Error clearing origin data:", error); + }); + break; + } + }, + + _clearOriginData: function(data) { + console.log("clearOriginData()"); + + if (!data) { + return Promise.resolve(); + } + + let pattern = JSON.parse(data); + return this._dropRegistrationsIf(record => + record.matchesOriginAttributes(pattern)); + }, + + /** + * Sends an unregister request to the server in the background. If the + * service is not connected, this function is a no-op. + * + * @param {PushRecord} record The record to unregister. + * @param {Number} reason An `nsIPushErrorReporter` unsubscribe reason, + * indicating why this record was removed. + */ + _backgroundUnregister(record, reason) { + console.debug("backgroundUnregister()"); + + if (!this._service.isConnected() || !record) { + return; + } + + console.debug("backgroundUnregister: Notifying server", record); + this._sendUnregister(record, reason).then(() => { + gPushNotifier.notifySubscriptionModified(record.scope, record.principal); + }).catch(e => { + console.error("backgroundUnregister: Error notifying server", e); + }); + }, + + _findService: function(serverURL) { + console.debug("findService()"); + + let uri; + let service; + + if (!serverURL) { + console.warn("findService: No dom.push.serverURL found"); + return []; + } + + try { + uri = Services.io.newURI(serverURL, null, null); + } catch (e) { + console.warn("findService: Error creating valid URI from", + "dom.push.serverURL", serverURL); + return []; + } + + for (let connProtocol of CONNECTION_PROTOCOLS) { + if (connProtocol.validServerURI(uri)) { + service = connProtocol; + break; + } + } + return [service, uri]; + }, + + _changeServerURL: function(serverURI, event, options = {}) { + console.debug("changeServerURL()"); + + switch(event) { + case UNINIT_EVENT: + return this._stopService(event); + + case STARTING_SERVICE_EVENT: + { + let [service, uri] = this._findService(serverURI); + if (!service) { + this._setState(PUSH_SERVICE_INIT); + return Promise.resolve(); + } + return this._startService(service, uri, options) + .then(_ => this._changeStateConnectionEnabledEvent(prefs.get("connection.enabled")) + ); + } + case CHANGING_SERVICE_EVENT: + let [service, uri] = this._findService(serverURI); + if (service) { + if (this._state == PUSH_SERVICE_INIT) { + this._setState(PUSH_SERVICE_ACTIVATING); + // The service has not been running - start it. + return this._startService(service, uri, options) + .then(_ => this._changeStateConnectionEnabledEvent(prefs.get("connection.enabled")) + ); + + } else { + this._setState(PUSH_SERVICE_ACTIVATING); + // If we already had running service - stop service, start the new + // one and check connection.enabled and offline state(offline state + // check is called in changeStateConnectionEnabledEvent function) + return this._stopService(CHANGING_SERVICE_EVENT) + .then(_ => + this._startService(service, uri, options) + ) + .then(_ => this._changeStateConnectionEnabledEvent(prefs.get("connection.enabled")) + ); + + } + } else { + if (this._state == PUSH_SERVICE_INIT) { + return Promise.resolve(); + + } else { + // The new serverUri is empty or misconfigured - stop service. + this._setState(PUSH_SERVICE_INIT); + return this._stopService(STOPPING_SERVICE_EVENT); + } + } + default: + console.error("Unexpected event in _changeServerURL", event); + return Promise.reject(new Error(`Unexpected event ${event}`)); + } + }, + + /** + * PushService initialization is divided into 4 parts: + * init() - start listening for quit-application and serverURL changes. + * state is change to PUSH_SERVICE_INIT + * startService() - if serverURL is present this function is called. It starts + * listening for broadcasted messages, starts db and + * PushService connection (WebSocket). + * state is change to PUSH_SERVICE_ACTIVATING. + * startObservers() - start other observers. + * changeStateConnectionEnabledEvent - checks prefs and offline state. + * It changes state to: + * PUSH_SERVICE_RUNNING, + * PUSH_SERVICE_ACTIVE_OFFLINE or + * PUSH_SERVICE_CONNECTION_DISABLE. + */ + init: function(options = {}) { + console.debug("init()"); + + if (this._state > PUSH_SERVICE_UNINIT) { + return; + } + + this._setState(PUSH_SERVICE_ACTIVATING); + + prefs.observe("serverURL", this); + Services.obs.addObserver(this, "quit-application", false); + + if (options.serverURI) { + // this is use for xpcshell test. + + this._stateChangeProcessEnqueue(_ => + this._changeServerURL(options.serverURI, STARTING_SERVICE_EVENT, options)); + + } else { + // This is only used for testing. Different tests require connecting to + // slightly different URLs. + this._stateChangeProcessEnqueue(_ => + this._changeServerURL(prefs.get("serverURL"), STARTING_SERVICE_EVENT)); + } + }, + + _startObservers: function() { + console.debug("startObservers()"); + + if (this._state != PUSH_SERVICE_ACTIVATING) { + return; + } + + Services.obs.addObserver(this, "clear-origin-attributes-data", false); + + // The offline-status-changed event is used to know + // when to (dis)connect. It may not fire if the underlying OS changes + // networks; in such a case we rely on timeout. + Services.obs.addObserver(this, "network:offline-status-changed", false); + + // Used to monitor if the user wishes to disable Push. + prefs.observe("connection.enabled", this); + + // Prunes expired registrations and notifies dormant service workers. + Services.obs.addObserver(this, "idle-daily", false); + + // Prunes registrations for sites for which the user revokes push + // permissions. + Services.obs.addObserver(this, "perm-changed", false); + }, + + _startService(service, serverURI, options) { + console.debug("startService()"); + + if (this._state != PUSH_SERVICE_ACTIVATING) { + return Promise.reject(); + } + + this._service = service; + + this._db = options.db; + if (!this._db) { + this._db = this._service.newPushDB(); + } + + return this._service.init(options, this, serverURI) + .then(() => { + this._startObservers(); + return this._dropExpiredRegistrations(); + }); + }, + + /** + * PushService uninitialization is divided into 3 parts: + * stopObservers() - stot observers started in startObservers. + * stopService() - It stops listening for broadcasted messages, stops db and + * PushService connection (WebSocket). + * state is changed to PUSH_SERVICE_INIT. + * uninit() - stop listening for quit-application and serverURL changes. + * state is change to PUSH_SERVICE_UNINIT + */ + _stopService: function(event) { + console.debug("stopService()"); + + if (this._state < PUSH_SERVICE_ACTIVATING) { + return Promise.resolve(); + } + + this._stopObservers(); + + this._service.disconnect(); + this._service.uninit(); + this._service = null; + + this._updateQuotaTimeouts.forEach((timeoutID) => clearTimeout(timeoutID)); + this._updateQuotaTimeouts.clear(); + + if (!this._db) { + return Promise.resolve(); + } + if (event == UNINIT_EVENT) { + // If it is uninitialized just close db. + this._db.close(); + this._db = null; + return Promise.resolve(); + } + + return this.dropUnexpiredRegistrations() + .then(_ => { + this._db.close(); + this._db = null; + }, err => { + this._db.close(); + this._db = null; + }); + }, + + _stopObservers: function() { + console.debug("stopObservers()"); + + if (this._state < PUSH_SERVICE_ACTIVATING) { + return; + } + + prefs.ignore("connection.enabled", this); + + Services.obs.removeObserver(this, "network:offline-status-changed"); + Services.obs.removeObserver(this, "clear-origin-attributes-data"); + Services.obs.removeObserver(this, "idle-daily"); + Services.obs.removeObserver(this, "perm-changed"); + }, + + _shutdownService() { + let promiseChangeURL = this._changeServerURL("", UNINIT_EVENT); + this._setState(PUSH_SERVICE_UNINIT); + console.debug("shutdownService: shutdown complete!"); + return promiseChangeURL; + }, + + uninit: function() { + console.debug("uninit()"); + + if (this._state == PUSH_SERVICE_UNINIT) { + return; + } + + prefs.ignore("serverURL", this); + Services.obs.removeObserver(this, "quit-application"); + + this._stateChangeProcessEnqueue(_ => this._shutdownService()); + }, + + /** + * Drops all active registrations and notifies the associated service + * workers. This function is called when the user switches Push servers, + * or when the server invalidates all existing registrations. + * + * We ignore expired registrations because they're already handled in other + * code paths. Registrations that expired after exceeding their quotas are + * evicted at startup, or on the next `idle-daily` event. Registrations that + * expired because the user revoked the notification permission are evicted + * once the permission is reinstated. + */ + dropUnexpiredRegistrations: function() { + return this._db.clearIf(record => { + if (record.isExpired()) { + return false; + } + this._notifySubscriptionChangeObservers(record); + return true; + }); + }, + + _notifySubscriptionChangeObservers: function(record) { + if (!record) { + return; + } + + Services.telemetry.getHistogramById("PUSH_API_NOTIFY_REGISTRATION_LOST").add(); + gPushNotifier.notifySubscriptionChange(record.scope, record.principal); + }, + + /** + * Drops a registration and notifies the associated service worker. If the + * registration does not exist, this function is a no-op. + * + * @param {String} keyID The registration ID to remove. + * @returns {Promise} Resolves once the worker has been notified. + */ + dropRegistrationAndNotifyApp: function(aKeyID) { + return this._db.delete(aKeyID) + .then(record => this._notifySubscriptionChangeObservers(record)); + }, + + /** + * Replaces an existing registration and notifies the associated service + * worker. + * + * @param {String} aOldKey The registration ID to replace. + * @param {PushRecord} aNewRecord The new record. + * @returns {Promise} Resolves once the worker has been notified. + */ + updateRegistrationAndNotifyApp: function(aOldKey, aNewRecord) { + return this.updateRecordAndNotifyApp(aOldKey, _ => aNewRecord); + }, + /** + * Updates a registration and notifies the associated service worker. + * + * @param {String} keyID The registration ID to update. + * @param {Function} updateFunc Returns the updated record. + * @returns {Promise} Resolves with the updated record once the worker + * has been notified. + */ + updateRecordAndNotifyApp: function(aKeyID, aUpdateFunc) { + return this._db.update(aKeyID, aUpdateFunc) + .then(record => { + this._notifySubscriptionChangeObservers(record); + return record; + }); + }, + + ensureCrypto: function(record) { + if (record.hasAuthenticationSecret() && + record.p256dhPublicKey && + record.p256dhPrivateKey) { + return Promise.resolve(record); + } + + let keygen = Promise.resolve([]); + if (!record.p256dhPublicKey || !record.p256dhPrivateKey) { + keygen = PushCrypto.generateKeys(); + } + // We do not have a encryption key. so we need to generate it. This + // is only going to happen on db upgrade from version 4 to higher. + return keygen + .then(([pubKey, privKey]) => { + return this.updateRecordAndNotifyApp(record.keyID, record => { + if (!record.p256dhPublicKey || !record.p256dhPrivateKey) { + record.p256dhPublicKey = pubKey; + record.p256dhPrivateKey = privKey; + } + if (!record.hasAuthenticationSecret()) { + record.authenticationSecret = PushCrypto.generateAuthenticationSecret(); + } + return record; + }); + }, error => { + return this.dropRegistrationAndNotifyApp(record.keyID).then( + () => Promise.reject(error)); + }); + }, + + _recordDidNotNotify: function(reason) { + Services.telemetry. + getHistogramById("PUSH_API_NOTIFICATION_RECEIVED_BUT_DID_NOT_NOTIFY"). + add(reason); + }, + + /** + * Dispatches an incoming message to a service worker, recalculating the + * quota for the associated push registration. If the quota is exceeded, + * the registration and message will be dropped, and the worker will not + * be notified. + * + * @param {String} keyID The push registration ID. + * @param {String} messageID The message ID, used to report service worker + * delivery failures. For Web Push messages, this is the version. If empty, + * failures will not be reported. + * @param {Object} headers The encryption headers. + * @param {ArrayBuffer|Uint8Array} data The encrypted message data. + * @param {Function} updateFunc A function that receives the existing + * registration record as its argument, and returns a new record. If the + * function returns `null` or `undefined`, the record will not be updated. + * `PushServiceWebSocket` uses this to drop incoming updates with older + * versions. + * @returns {Promise} Resolves with an `nsIPushErrorReporter` ack status + * code, indicating whether the message was delivered successfully. + */ + receivedPushMessage(keyID, messageID, headers, data, updateFunc) { + console.debug("receivedPushMessage()"); + Services.telemetry.getHistogramById("PUSH_API_NOTIFICATION_RECEIVED").add(); + + return this._updateRecordAfterPush(keyID, updateFunc).then(record => { + if (record.quotaApplies()) { + // Update quota after the delay, at which point + // we check for visible notifications. + let timeoutID = setTimeout(_ => + { + this._updateQuota(keyID); + if (!this._updateQuotaTimeouts.delete(timeoutID)) { + console.debug("receivedPushMessage: quota update timeout missing?"); + } + }, prefs.get("quotaUpdateDelay")); + this._updateQuotaTimeouts.add(timeoutID); + } + return this._decryptAndNotifyApp(record, messageID, headers, data); + }).catch(error => { + console.error("receivedPushMessage: Error notifying app", error); + return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED; + }); + }, + + /** + * Updates a registration record after receiving a push message. + * + * @param {String} keyID The push registration ID. + * @param {Function} updateFunc The function passed to `receivedPushMessage`. + * @returns {Promise} Resolves with the updated record, or rejects if the + * record was not updated. + */ + _updateRecordAfterPush(keyID, updateFunc) { + return this.getByKeyID(keyID).then(record => { + if (!record) { + this._recordDidNotNotify(kDROP_NOTIFICATION_REASON_KEY_NOT_FOUND); + throw new Error("No record for key ID " + keyID); + } + return record.getLastVisit().then(lastVisit => { + // As a special case, don't notify the service worker if the user + // cleared their history. + if (!isFinite(lastVisit)) { + this._recordDidNotNotify(kDROP_NOTIFICATION_REASON_NO_HISTORY); + throw new Error("Ignoring message sent to unvisited origin"); + } + return lastVisit; + }).then(lastVisit => { + // Update the record, resetting the quota if the user has visited the + // site since the last push. + return this._db.update(keyID, record => { + let newRecord = updateFunc(record); + if (!newRecord) { + this._recordDidNotNotify(kDROP_NOTIFICATION_REASON_NO_VERSION_INCREMENT); + return null; + } + // Because `unregister` is advisory only, we can still receive messages + // for stale Simple Push registrations from the server. To work around + // this, we check if the record has expired before *and* after updating + // the quota. + if (newRecord.isExpired()) { + return null; + } + newRecord.receivedPush(lastVisit); + return newRecord; + }); + }); + }).then(record => { + gPushNotifier.notifySubscriptionModified(record.scope, + record.principal); + return record; + }); + }, + + /** + * Decrypts an incoming message and notifies the associated service worker. + * + * @param {PushRecord} record The receiving registration. + * @param {String} messageID The message ID. + * @param {Object} headers The encryption headers. + * @param {ArrayBuffer|Uint8Array} data The encrypted message data. + * @returns {Promise} Resolves with an ack status code. + */ + _decryptAndNotifyApp(record, messageID, headers, data) { + return PushCrypto.decrypt(record.p256dhPrivateKey, record.p256dhPublicKey, + record.authenticationSecret, headers, data) + .then( + message => this._notifyApp(record, messageID, message), + error => { + console.warn("decryptAndNotifyApp: Error decrypting message", + record.scope, messageID, error); + + let message = error.format(record.scope); + gPushNotifier.notifyError(record.scope, record.principal, message, + Ci.nsIScriptError.errorFlag); + return Ci.nsIPushErrorReporter.ACK_DECRYPTION_ERROR; + }); + }, + + _updateQuota: function(keyID) { + console.debug("updateQuota()"); + + this._db.update(keyID, record => { + // Record may have expired from an earlier quota update. + if (record.isExpired()) { + console.debug( + "updateQuota: Trying to update quota for expired record", record); + return null; + } + // If there are visible notifications, don't apply the quota penalty + // for the message. + if (record.uri && !this._visibleNotifications.has(record.uri.prePath)) { + record.reduceQuota(); + } + return record; + }).then(record => { + if (record.isExpired()) { + this._recordDidNotNotify(kDROP_NOTIFICATION_REASON_EXPIRED); + // Drop the registration in the background. If the user returns to the + // site, the service worker will be notified on the next `idle-daily` + // event. + this._backgroundUnregister(record, + Ci.nsIPushErrorReporter.UNSUBSCRIBE_QUOTA_EXCEEDED); + } else { + gPushNotifier.notifySubscriptionModified(record.scope, + record.principal); + } + if (this._updateQuotaTestCallback) { + // Callback so that test may be notified when the quota update is complete. + this._updateQuotaTestCallback(); + } + }).catch(error => { + console.debug("updateQuota: Error while trying to update quota", error); + }); + }, + + notificationForOriginShown(origin) { + console.debug("notificationForOriginShown()", origin); + let count; + if (this._visibleNotifications.has(origin)) { + count = this._visibleNotifications.get(origin); + } else { + count = 0; + } + this._visibleNotifications.set(origin, count + 1); + }, + + notificationForOriginClosed(origin) { + console.debug("notificationForOriginClosed()", origin); + let count; + if (this._visibleNotifications.has(origin)) { + count = this._visibleNotifications.get(origin); + } else { + console.debug("notificationForOriginClosed: closing notification that has not been shown?"); + return; + } + if (count > 1) { + this._visibleNotifications.set(origin, count - 1); + } else { + this._visibleNotifications.delete(origin); + } + }, + + reportDeliveryError(messageID, reason) { + console.debug("reportDeliveryError()", messageID, reason); + if (this._state == PUSH_SERVICE_RUNNING && + this._service.isConnected()) { + + // Only report errors if we're initialized and connected. + this._service.reportDeliveryError(messageID, reason); + } + }, + + _notifyApp(aPushRecord, messageID, message) { + if (!aPushRecord || !aPushRecord.scope || + aPushRecord.originAttributes === undefined) { + console.error("notifyApp: Invalid record", aPushRecord); + return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED; + } + + console.debug("notifyApp()", aPushRecord.scope); + + // If permission has been revoked, trash the message. + if (!aPushRecord.hasPermission()) { + console.warn("notifyApp: Missing push permission", aPushRecord); + return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED; + } + + let payload = ArrayBuffer.isView(message) ? + new Uint8Array(message.buffer) : message; + + if (aPushRecord.quotaApplies()) { + // Don't record telemetry for chrome push messages. + Services.telemetry.getHistogramById("PUSH_API_NOTIFY").add(); + } + + if (payload) { + gPushNotifier.notifyPushWithData(aPushRecord.scope, + aPushRecord.principal, + messageID, payload.length, payload); + } else { + gPushNotifier.notifyPush(aPushRecord.scope, aPushRecord.principal, + messageID); + } + + return Ci.nsIPushErrorReporter.ACK_DELIVERED; + }, + + getByKeyID: function(aKeyID) { + return this._db.getByKeyID(aKeyID); + }, + + getAllUnexpired: function() { + return this._db.getAllUnexpired(); + }, + + _sendRequest(action, ...params) { + if (this._state == PUSH_SERVICE_CONNECTION_DISABLE) { + return Promise.reject(new Error("Push service disabled")); + } + if (this._state == PUSH_SERVICE_ACTIVE_OFFLINE) { + return Promise.reject(new Error("Push service offline")); + } + // Ensure the backend is ready. `getByPageRecord` already checks this, but + // we need to check again here in case the service was restarted in the + // meantime. + return this._checkActivated().then(_ => { + switch (action) { + case "register": + return this._service.register(...params); + case "unregister": + return this._service.unregister(...params); + } + return Promise.reject(new Error("Unknown request type: " + action)); + }); + }, + + /** + * Called on message from the child process. aPageRecord is an object sent by + * the push manager, identifying the sending page and other fields. + */ + _registerWithServer: function(aPageRecord) { + console.debug("registerWithServer()", aPageRecord); + + Services.telemetry.getHistogramById("PUSH_API_SUBSCRIBE_ATTEMPT").add(); + return this._sendRequest("register", aPageRecord) + .then(record => this._onRegisterSuccess(record), + err => this._onRegisterError(err)) + .then(record => { + this._deletePendingRequest(aPageRecord); + gPushNotifier.notifySubscriptionModified(record.scope, + record.principal); + return record.toSubscription(); + }, err => { + this._deletePendingRequest(aPageRecord); + throw err; + }); + }, + + _sendUnregister(aRecord, aReason) { + Services.telemetry.getHistogramById("PUSH_API_UNSUBSCRIBE_ATTEMPT").add(); + return this._sendRequest("unregister", aRecord, aReason).then(function(v) { + Services.telemetry.getHistogramById("PUSH_API_UNSUBSCRIBE_SUCCEEDED").add(); + return v; + }).catch(function(e) { + Services.telemetry.getHistogramById("PUSH_API_UNSUBSCRIBE_FAILED").add(); + return Promise.reject(e); + }); + }, + + /** + * Exceptions thrown in _onRegisterSuccess are caught by the promise obtained + * from _service.request, causing the promise to be rejected instead. + */ + _onRegisterSuccess: function(aRecord) { + console.debug("_onRegisterSuccess()"); + + return this._db.put(aRecord) + .then(record => { + Services.telemetry.getHistogramById("PUSH_API_SUBSCRIBE_SUCCEEDED").add(); + return record; + }) + .catch(error => { + Services.telemetry.getHistogramById("PUSH_API_SUBSCRIBE_FAILED").add() + // Unable to save. Destroy the subscription in the background. + this._backgroundUnregister(aRecord, + Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL); + throw error; + }); + }, + + /** + * Exceptions thrown in _onRegisterError are caught by the promise obtained + * from _service.request, causing the promise to be rejected instead. + */ + _onRegisterError: function(reply) { + console.debug("_onRegisterError()"); + Services.telemetry.getHistogramById("PUSH_API_SUBSCRIBE_FAILED").add() + if (!reply.error) { + console.warn("onRegisterError: Called without valid error message!", + reply); + throw new Error("Registration error"); + } + throw reply.error; + }, + + notificationsCleared() { + this._visibleNotifications.clear(); + }, + + _getByPageRecord(pageRecord) { + return this._checkActivated().then(_ => + this._db.getByIdentifiers(pageRecord) + ); + }, + + register: function(aPageRecord) { + console.debug("register()", aPageRecord); + + let keyPromise; + if (aPageRecord.appServerKey) { + let keyView = new Uint8Array(aPageRecord.appServerKey); + keyPromise = PushCrypto.validateAppServerKey(keyView) + .catch(error => { + // Normalize Web Crypto exceptions. `nsIPushService` will forward the + // error result to the DOM API implementation in `PushManager.cpp` or + // `Push.js`, which will convert it to the correct `DOMException`. + throw errorWithResult("Invalid app server key", + Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR); + }); + } else { + keyPromise = Promise.resolve(null); + } + + return Promise.all([ + keyPromise, + this._getByPageRecord(aPageRecord), + ]).then(([appServerKey, record]) => { + aPageRecord.appServerKey = appServerKey; + if (!record) { + return this._lookupOrPutPendingRequest(aPageRecord); + } + if (!record.matchesAppServerKey(appServerKey)) { + throw errorWithResult("Mismatched app server key", + Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR); + } + if (record.isExpired()) { + return record.quotaChanged().then(isChanged => { + if (isChanged) { + // If the user revisited the site, drop the expired push + // registration and re-register. + return this.dropRegistrationAndNotifyApp(record.keyID); + } + throw new Error("Push subscription expired"); + }).then(_ => this._lookupOrPutPendingRequest(aPageRecord)); + } + return record.toSubscription(); + }); + }, + + /** + * Called on message from the child process. + * + * Why is the record being deleted from the local database before the server + * is told? + * + * Unregistration is for the benefit of the app and the AppServer + * so that the AppServer does not keep pinging a channel the UserAgent isn't + * watching The important part of the transaction in this case is left to the + * app, to tell its server of the unregistration. Even if the request to the + * PushServer were to fail, it would not affect correctness of the protocol, + * and the server GC would just clean up the channelID/subscription + * eventually. Since the appserver doesn't ping it, no data is lost. + * + * If rather we were to unregister at the server and update the database only + * on success: If the server receives the unregister, and deletes the + * channelID/subscription, but the response is lost because of network + * failure, the application is never informed. In addition the application may + * retry the unregister when it fails due to timeout (websocket) or any other + * reason at which point the server will say it does not know of this + * unregistration. We'll have to make the registration/unregistration phases + * have retries and attempts to resend messages from the server, and have the + * client acknowledge. On a server, data is cheap, reliable notification is + * not. + */ + unregister: function(aPageRecord) { + console.debug("unregister()", aPageRecord); + + return this._getByPageRecord(aPageRecord) + .then(record => { + if (record === undefined) { + return false; + } + + let reason = Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL; + return Promise.all([ + this._sendUnregister(record, reason), + this._db.delete(record.keyID).then(record => { + if (record) { + gPushNotifier.notifySubscriptionModified(record.scope, + record.principal); + } + }), + ]).then(([success]) => success); + }); + }, + + clear: function(info) { + return this._checkActivated() + .then(_ => { + return this._dropRegistrationsIf(record => + info.domain == "*" || + (record.uri && hasRootDomain(record.uri.prePath, info.domain)) + ); + }) + .catch(e => { + console.warn("clear: Error dropping subscriptions for domain", + info.domain, e); + return Promise.resolve(); + }); + }, + + registration: function(aPageRecord) { + console.debug("registration()"); + + return this._getByPageRecord(aPageRecord) + .then(record => { + if (!record) { + return null; + } + if (record.isExpired()) { + return record.quotaChanged().then(isChanged => { + if (isChanged) { + return this.dropRegistrationAndNotifyApp(record.keyID).then(_ => null); + } + return null; + }); + } + return record.toSubscription(); + }); + }, + + _dropExpiredRegistrations: function() { + console.debug("dropExpiredRegistrations()"); + + return this._db.getAllExpired().then(records => { + return Promise.all(records.map(record => + record.quotaChanged().then(isChanged => { + if (isChanged) { + // If the user revisited the site, drop the expired push + // registration and notify the associated service worker. + return this.dropRegistrationAndNotifyApp(record.keyID); + } + }).catch(error => { + console.error("dropExpiredRegistrations: Error dropping registration", + record.keyID, error); + }) + )); + }); + }, + + _onPermissionChange: function(subject, data) { + console.debug("onPermissionChange()"); + + if (data == "cleared") { + return this._clearPermissions(); + } + + let permission = subject.QueryInterface(Ci.nsIPermission); + if (permission.type != "desktop-notification") { + return Promise.resolve(); + } + + return this._updatePermission(permission, data); + }, + + _clearPermissions() { + console.debug("clearPermissions()"); + + return this._db.clearIf(record => { + if (!record.quotaApplies()) { + // Only drop registrations that are subject to quota. + return false; + } + this._backgroundUnregister(record, + Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED); + return true; + }); + }, + + _updatePermission: function(permission, type) { + console.debug("updatePermission()"); + + let isAllow = permission.capability == + Ci.nsIPermissionManager.ALLOW_ACTION; + let isChange = type == "added" || type == "changed"; + + if (isAllow && isChange) { + // Permission set to "allow". Drop all expired registrations for this + // site, notify the associated service workers, and reset the quota + // for active registrations. + return this._forEachPrincipal( + permission.principal, + (record, cursor) => this._permissionAllowed(record, cursor) + ); + } else if (isChange || (isAllow && type == "deleted")) { + // Permission set to "block" or "always ask," or "allow" permission + // removed. Expire all registrations for this site. + return this._forEachPrincipal( + permission.principal, + (record, cursor) => this._permissionDenied(record, cursor) + ); + } + + return Promise.resolve(); + }, + + _forEachPrincipal: function(principal, callback) { + return this._db.forEachOrigin( + principal.URI.prePath, + ChromeUtils.originAttributesToSuffix(principal.originAttributes), + callback + ); + }, + + /** + * The update function called for each registration record if the push + * permission is revoked. We only expire the record so we can notify the + * service worker as soon as the permission is reinstated. If we just + * deleted the record, the worker wouldn't be notified until the next visit + * to the site. + * + * @param {PushRecord} record The record to expire. + * @param {IDBCursor} cursor The IndexedDB cursor. + */ + _permissionDenied: function(record, cursor) { + console.debug("permissionDenied()"); + + if (!record.quotaApplies() || record.isExpired()) { + // Ignore already-expired records. + return; + } + // Drop the registration in the background. + this._backgroundUnregister(record, + Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED); + record.setQuota(0); + cursor.update(record); + }, + + /** + * The update function called for each registration record if the push + * permission is granted. If the record has expired, it will be dropped; + * otherwise, its quota will be reset to the default value. + * + * @param {PushRecord} record The record to update. + * @param {IDBCursor} cursor The IndexedDB cursor. + */ + _permissionAllowed(record, cursor) { + console.debug("permissionAllowed()"); + + if (!record.quotaApplies()) { + return; + } + if (record.isExpired()) { + // If the registration has expired, drop and notify the worker + // unconditionally. + this._notifySubscriptionChangeObservers(record); + cursor.delete(); + return; + } + record.resetQuota(); + cursor.update(record); + }, + + /** + * Drops all matching registrations from the database. Notifies the + * associated service workers if permission is granted, and removes + * unexpired registrations from the server. + * + * @param {Function} predicate A function called for each record. + * @returns {Promise} Resolves once the registrations have been dropped. + */ + _dropRegistrationsIf(predicate) { + return this._db.clearIf(record => { + if (!predicate(record)) { + return false; + } + if (record.hasPermission()) { + // "Clear Recent History" and the Forget button remove permissions + // before clearing registrations, but it's possible for the worker to + // resubscribe if the "dom.push.testing.ignorePermission" pref is set. + this._notifySubscriptionChangeObservers(record); + } + if (!record.isExpired()) { + // Only unregister active registrations, since we already told the + // server about expired ones. + this._backgroundUnregister(record, + Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL); + } + return true; + }); + }, +}; |