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