Utils.sys.mjs (18258B)
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 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 6 import { ServiceRequest } from "resource://gre/modules/ServiceRequest.sys.mjs"; 7 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 8 9 const lazy = {}; 10 11 ChromeUtils.defineESModuleGetters(lazy, { 12 SharedUtils: "resource://services-settings/SharedUtils.sys.mjs", 13 }); 14 15 XPCOMUtils.defineLazyServiceGetter( 16 lazy, 17 "CaptivePortalService", 18 "@mozilla.org/network/captive-portal-service;1", 19 Ci.nsICaptivePortalService 20 ); 21 XPCOMUtils.defineLazyServiceGetter( 22 lazy, 23 "gNetworkLinkService", 24 "@mozilla.org/network/network-link-service;1", 25 Ci.nsINetworkLinkService 26 ); 27 28 // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref. 29 // See LOG_LEVELS in Console.sys.mjs. Common examples: "all", "debug", "info", 30 // "warn", "error". 31 const log = (() => { 32 const { ConsoleAPI } = ChromeUtils.importESModule( 33 "resource://gre/modules/Console.sys.mjs" 34 ); 35 return new ConsoleAPI({ 36 maxLogLevel: "warn", 37 maxLogLevelPref: "services.settings.loglevel", 38 prefix: "services.settings", 39 }); 40 })(); 41 42 ChromeUtils.defineLazyGetter(lazy, "isRunningTests", () => { 43 if (Services.env.get("MOZ_DISABLE_NONLOCAL_CONNECTIONS") === "1") { 44 // Allow to override the server URL if non-local connections are disabled, 45 // usually true when running tests. 46 return true; 47 } 48 return false; 49 }); 50 51 // Overriding the server URL is normally disabled on Beta and Release channels, 52 // except under some conditions. 53 ChromeUtils.defineLazyGetter(lazy, "allowServerURLOverride", () => { 54 if (!AppConstants.RELEASE_OR_BETA) { 55 // Always allow to override the server URL on Nightly/DevEdition. 56 return true; 57 } 58 59 if (lazy.isRunningTests) { 60 return true; 61 } 62 63 if (Services.env.get("MOZ_REMOTE_SETTINGS_DEVTOOLS") === "1") { 64 // Allow to override the server URL when using remote settings devtools. 65 return true; 66 } 67 68 if (lazy.gServerURL != AppConstants.REMOTE_SETTINGS_SERVER_URL) { 69 log.warn("Ignoring preference override of remote settings server"); 70 log.warn( 71 "Allow by setting MOZ_REMOTE_SETTINGS_DEVTOOLS=1 in the environment" 72 ); 73 } 74 75 return false; 76 }); 77 78 XPCOMUtils.defineLazyPreferenceGetter( 79 lazy, 80 "gServerURL", 81 "services.settings.server", 82 AppConstants.REMOTE_SETTINGS_SERVER_URL 83 ); 84 85 XPCOMUtils.defineLazyPreferenceGetter( 86 lazy, 87 "gPreviewEnabled", 88 "services.settings.preview_enabled", 89 false 90 ); 91 92 function _isUndefined(value) { 93 return typeof value === "undefined"; 94 } 95 96 const _cdnURLs = {}; 97 98 export var Utils = { 99 get SERVER_URL() { 100 return lazy.allowServerURLOverride 101 ? lazy.gServerURL 102 : AppConstants.REMOTE_SETTINGS_SERVER_URL; 103 }, 104 105 CHANGES_PATH: "/buckets/monitor/collections/changes/changeset", 106 107 /** 108 * Logger instance. 109 */ 110 log, 111 112 get shouldSkipRemoteActivityDueToTests() { 113 return ( 114 (lazy.isRunningTests || Cu.isInAutomation) && 115 this.SERVER_URL == "data:,#remote-settings-dummy/v1" 116 ); 117 }, 118 119 get CERT_CHAIN_ROOT_IDENTIFIER() { 120 if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) { 121 return Ci.nsIX509CertDB.AppXPCShellRoot; 122 } 123 if ( 124 this.SERVER_URL.match( 125 /^https?:\/\/(remote-settings\.localhost|127\.0\.0\.1|localhost)(:\d+)?\/v1/ 126 ) 127 ) { 128 return Ci.nsIContentSignatureVerifier.ContentSignatureLocalRoot; 129 } 130 if (this.SERVER_URL.includes("allizom.")) { 131 return Ci.nsIContentSignatureVerifier.ContentSignatureStageRoot; 132 } 133 if (this.SERVER_URL.includes("dev.")) { 134 return Ci.nsIContentSignatureVerifier.ContentSignatureDevRoot; 135 } 136 return Ci.nsIContentSignatureVerifier.ContentSignatureProdRoot; 137 }, 138 139 get LOAD_DUMPS() { 140 // Load dumps only if pulling data from the production server, or in tests. 141 return ( 142 this.SERVER_URL == AppConstants.REMOTE_SETTINGS_SERVER_URL || 143 lazy.isRunningTests 144 ); 145 }, 146 147 get PREVIEW_MODE() { 148 // We want to offer the ability to set preview mode via a preference 149 // for consumers who want to pull from the preview bucket on startup. 150 if (_isUndefined(this._previewModeEnabled) && lazy.allowServerURLOverride) { 151 return lazy.gPreviewEnabled; 152 } 153 return !!this._previewModeEnabled; 154 }, 155 156 /** 157 * Internal method to enable pulling data from preview buckets. 158 * 159 * @param enabled 160 */ 161 enablePreviewMode(enabled) { 162 const bool2str = v => 163 // eslint-disable-next-line no-nested-ternary 164 _isUndefined(v) ? "unset" : v ? "enabled" : "disabled"; 165 this.log.debug( 166 `Preview mode: ${bool2str(this._previewModeEnabled)} -> ${bool2str( 167 enabled 168 )}` 169 ); 170 this._previewModeEnabled = enabled; 171 }, 172 173 /** 174 * Returns the actual bucket name to be used. When preview mode is enabled, 175 * this adds the *preview* suffix. 176 * 177 * See also `SharedUtils.loadJSONDump()` which strips the preview suffix to identify 178 * the packaged JSON file. 179 * 180 * @param bucketName the client bucket 181 * @returns the final client bucket depending whether preview mode is enabled. 182 */ 183 actualBucketName(bucketName) { 184 let actual = bucketName.replace("-preview", ""); 185 if (this.PREVIEW_MODE) { 186 actual += "-preview"; 187 } 188 return actual; 189 }, 190 191 /** 192 * Check if network is down. 193 * 194 * Note that if this returns false, it does not guarantee 195 * that network is up. 196 * 197 * @return {bool} Whether network is down or not. 198 */ 199 get isOffline() { 200 try { 201 return ( 202 Services.io.offline || 203 lazy.CaptivePortalService.state == 204 lazy.CaptivePortalService.LOCKED_PORTAL || 205 !lazy.gNetworkLinkService.isLinkUp 206 ); 207 } catch (ex) { 208 log.warn("Could not determine network status.", ex); 209 } 210 return false; 211 }, 212 213 /** 214 * A wrapper around `ServiceRequest` that behaves like `fetch()`. 215 * 216 * Use this in order to leverage the `beConservative` flag, for 217 * example to avoid using HTTP3 to fetch critical data. 218 * 219 * @param input a resource 220 * @param init request options 221 * @returns a Response object 222 */ 223 async fetch(input, init = {}) { 224 return new Promise(function (resolve, reject) { 225 const request = new ServiceRequest(); 226 function fallbackOrReject(err) { 227 if ( 228 // At most one recursive Utils.fetch call (bypassProxy=false to true). 229 bypassProxy || 230 Services.startup.shuttingDown || 231 Utils.isOffline || 232 !request.isProxied || 233 !request.bypassProxyEnabled 234 ) { 235 reject(err); 236 return; 237 } 238 ServiceRequest.logProxySource(request.channel, "remote-settings"); 239 resolve(Utils.fetch(input, { ...init, bypassProxy: true })); 240 } 241 242 request.onerror = () => 243 fallbackOrReject(new TypeError("NetworkError: Network request failed")); 244 request.ontimeout = () => 245 fallbackOrReject(new TypeError("Timeout: Network request failed")); 246 request.onabort = () => 247 fallbackOrReject(new DOMException("Aborted", "AbortError")); 248 request.onload = () => { 249 // Parse raw response headers into `Headers` object. 250 const headers = new Headers(); 251 const rawHeaders = request.getAllResponseHeaders(); 252 rawHeaders 253 .trim() 254 .split(/[\r\n]+/) 255 .forEach(line => { 256 const parts = line.split(": "); 257 const header = parts.shift(); 258 const value = parts.join(": "); 259 headers.set(header, value); 260 }); 261 262 const responseAttributes = { 263 status: request.status, 264 statusText: request.statusText, 265 url: request.responseURL, 266 headers, 267 }; 268 resolve(new Response(request.response, responseAttributes)); 269 }; 270 271 const { method = "GET", headers = {}, bypassProxy = false } = init; 272 273 request.open(method, input, { bypassProxy }); 274 // By default, XMLHttpRequest converts the response based on the 275 // Content-Type header, or UTF-8 otherwise. This may mangle binary 276 // responses. Avoid that by requesting the raw bytes. 277 request.responseType = "arraybuffer"; 278 279 for (const [name, value] of Object.entries(headers)) { 280 request.setRequestHeader(name, value); 281 } 282 283 request.send(); 284 }); 285 }, 286 287 /** 288 * Retrieves the base URL for attachments from the server configuration. 289 * 290 * If the URL has been previously fetched and cached, it returns the cached URL. 291 * 292 * @async 293 * @function baseAttachmentsURL 294 * @memberof Utils 295 * @returns {Promise<string>} A promise that resolves to the base URL for attachments. 296 * 297 * @throws {Error} If there is an error fetching or parsing the server response. 298 * 299 * @example 300 * const attachmentsURL = await Downloader.baseAttachmentsURL(); 301 * console.log(attachmentsURL); 302 */ 303 async baseAttachmentsURL() { 304 if (!_cdnURLs[Utils.SERVER_URL]) { 305 const resp = await Utils.fetch(`${Utils.SERVER_URL}/`); 306 const serverInfo = await resp.json(); 307 // Server capabilities expose attachments configuration. 308 const { 309 capabilities: { 310 attachments: { base_url }, 311 }, 312 } = serverInfo; 313 // Make sure the URL always has a trailing slash. 314 _cdnURLs[Utils.SERVER_URL] = 315 base_url + (base_url.endsWith("/") ? "" : "/"); 316 } 317 return _cdnURLs[Utils.SERVER_URL]; 318 }, 319 320 /** 321 * Check if local data exist for the specified client. 322 * 323 * @param {RemoteSettingsClient} client 324 * @return {bool} Whether it exists or not. 325 */ 326 async hasLocalData(client) { 327 const timestamp = await client.db.getLastModified(); 328 return timestamp !== null; 329 }, 330 331 /** 332 * Check if we ship a JSON dump for the specified bucket and collection. 333 * 334 * @param {string} bucket 335 * @param {string} collection 336 * @return {bool} Whether it is present or not. 337 */ 338 async hasLocalDump(bucket, collection) { 339 try { 340 await fetch( 341 `resource://app/defaults/settings/${bucket}/${collection}.json`, 342 { 343 method: "HEAD", 344 } 345 ); 346 return true; 347 } catch (e) { 348 return false; 349 } 350 }, 351 352 /** 353 * Look up the last modification time of the JSON dump. 354 * 355 * @param {string} bucket 356 * @param {string} collection 357 * @return {int} The last modification time of the dump. -1 if non-existent. 358 */ 359 async getLocalDumpLastModified(bucket, collection) { 360 if (!this._dumpStats) { 361 if (!this._dumpStatsInitPromise) { 362 this._dumpStatsInitPromise = (async () => { 363 try { 364 let res = await fetch( 365 "resource://app/defaults/settings/last_modified.json" 366 ); 367 this._dumpStats = await res.json(); 368 } catch (e) { 369 log.warn(`Failed to load last_modified.json: ${e}`); 370 this._dumpStats = {}; 371 } 372 delete this._dumpStatsInitPromise; 373 })(); 374 } 375 await this._dumpStatsInitPromise; 376 } 377 const identifier = `${bucket}/${collection}`; 378 let lastModified = this._dumpStats[identifier]; 379 if (lastModified === undefined) { 380 const { timestamp: dumpTimestamp } = await lazy.SharedUtils.loadJSONDump( 381 bucket, 382 collection 383 ); 384 // Client recognize -1 as missing dump. 385 lastModified = dumpTimestamp ?? -1; 386 this._dumpStats[identifier] = lastModified; 387 } 388 return lastModified; 389 }, 390 391 /** 392 * Fetch the list of remote collections and their timestamp. 393 * ``` 394 * { 395 * "timestamp": 1486545678, 396 * "changes":[ 397 * { 398 * "host":"kinto-ota.dev.mozaws.net", 399 * "last_modified":1450717104423, 400 * "bucket":"blocklists", 401 * "collection":"certificates" 402 * }, 403 * ... 404 * ], 405 * "metadata": {} 406 * } 407 * ``` 408 * 409 * @param {string} serverUrl The server URL (eg. `https://server.org/v1`) 410 * @param {int} expectedTimestamp The timestamp that the server is supposed to return. 411 * We obtained it from the Megaphone notification payload, 412 * and we use it only for cache busting (Bug 1497159). 413 * @param {string} lastEtag (optional) The Etag of the latest poll to be matched 414 * by the server (eg. `"123456789"`). 415 * @param {object} filters 416 */ 417 async fetchLatestChanges(serverUrl, options = {}) { 418 const { expectedTimestamp, lastEtag = "", filters = {} } = options; 419 420 let url = serverUrl + Utils.CHANGES_PATH; 421 const params = { 422 ...filters, 423 _expected: expectedTimestamp ?? 0, 424 }; 425 if (lastEtag != "") { 426 params._since = lastEtag; 427 } 428 if (params) { 429 url += 430 "?" + 431 Object.entries(params) 432 .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) 433 .join("&"); 434 } 435 const response = await Utils.fetch(url); 436 437 if (response.status >= 500) { 438 throw new Error(`Server error ${response.status} ${response.statusText}`); 439 } 440 441 const is404FromCustomServer = 442 response.status == 404 && 443 Services.prefs.prefHasUserValue("services.settings.server"); 444 445 const ct = response.headers.get("Content-Type"); 446 if (!is404FromCustomServer && (!ct || !ct.includes("application/json"))) { 447 throw new Error(`Unexpected content-type "${ct}"`); 448 } 449 450 let payload; 451 try { 452 payload = await response.json(); 453 } catch (e) { 454 payload = e.message; 455 } 456 457 if (!payload.hasOwnProperty("changes")) { 458 // If the server is failing, the JSON response might not contain the 459 // expected data. For example, real server errors (Bug 1259145) 460 // or dummy local server for tests (Bug 1481348) 461 if (!is404FromCustomServer) { 462 throw new Error( 463 `Server error ${url} ${response.status} ${ 464 response.statusText 465 }: ${JSON.stringify(payload)}` 466 ); 467 } 468 } 469 470 const { changes = [], timestamp } = payload; 471 472 let serverTimeMillis = Date.parse(response.headers.get("Date")); 473 // Since the response is served via a CDN, the Date header value could have been cached. 474 const cacheAgeSeconds = response.headers.has("Age") 475 ? parseInt(response.headers.get("Age"), 10) 476 : 0; 477 serverTimeMillis += cacheAgeSeconds * 1000; 478 479 // Age of data (time between publication and now). 480 const ageSeconds = (serverTimeMillis - timestamp) / 1000; 481 482 // Check if the server asked the clients to back off. 483 let backoffSeconds; 484 if (response.headers.has("Backoff")) { 485 const value = parseInt(response.headers.get("Backoff"), 10); 486 if (!isNaN(value)) { 487 backoffSeconds = value; 488 } 489 } 490 491 return { 492 changes, 493 currentEtag: `"${timestamp}"`, 494 serverTimeMillis, 495 backoffSeconds, 496 ageSeconds, 497 }; 498 }, 499 500 /** 501 * Test if a single object matches all given filters. 502 * 503 * @param {object} filters The filters object. 504 * @param {object} entry The object to filter. 505 * @return {boolean} 506 */ 507 filterObject(filters, entry) { 508 return Object.entries(filters).every(([filter, value]) => { 509 if (Array.isArray(value)) { 510 return value.some(candidate => candidate === entry[filter]); 511 } else if (typeof value === "object") { 512 return Utils.filterObject(value, entry[filter]); 513 } else if (!Object.prototype.hasOwnProperty.call(entry, filter)) { 514 log.debug(`The property ${filter} does not exist`); 515 return false; 516 } 517 return entry[filter] === value; 518 }); 519 }, 520 521 /** 522 * Sorts records in a list according to a given ordering. 523 * 524 * @param {string} order The ordering, eg. `-last_modified`. 525 * @param {Array} list The collection to order. 526 * @return {Array} 527 */ 528 sortObjects(order, list) { 529 const hasDash = order[0] === "-"; 530 const field = hasDash ? order.slice(1) : order; 531 const direction = hasDash ? -1 : 1; 532 return list.slice().sort((a, b) => { 533 if (a[field] && _isUndefined(b[field])) { 534 return direction; 535 } 536 if (b[field] && _isUndefined(a[field])) { 537 return -direction; 538 } 539 if (_isUndefined(a[field]) && _isUndefined(b[field])) { 540 return 0; 541 } 542 return a[field] > b[field] ? direction : -direction; 543 }); 544 }, 545 546 /** 547 * Fetches and extracts a bundle of changesets from the server. 548 * 549 * This function downloads a JSON file with all changesets required during startup, compressed as LZ4. 550 * It writes the LZ4 file to a temporary location, extracts it, and return the array of changesets. 551 * We chose to use LZ4 instead of Zip because extraction can happen off the main thread. 552 * 553 * @async 554 * @function fetchChangesetsBundle 555 * @memberof Utils 556 * @returns {Promise<Array<object>>} A promise that resolves to an array of parsed changesets. 557 * 558 * @throws {Error} Throws an error if there is an issue fetching the server info or the changeset bundle, 559 * or if there is an error during the extraction and parsing of the changesets. 560 */ 561 async fetchChangesetsBundle() { 562 const tmpLz4File = await IOUtils.createUniqueFile( 563 PathUtils.tempDir, 564 "remote-settings-startup-bundle-" 565 ); 566 try { 567 const baseUrl = await Utils.baseAttachmentsURL(); 568 const bundleUrl = `${baseUrl}bundles/startup.json.mozlz4`; 569 const bundleResp = await Utils.fetch(bundleUrl); 570 if (!bundleResp.ok) { 571 throw new Error(`Cannot fetch changeset bundle from ${bundleUrl}`); 572 } 573 // Write down the LZ4 in a temporary file. 574 const downloaded = await bundleResp.arrayBuffer(); 575 await IOUtils.write(tmpLz4File, new Uint8Array(downloaded), { 576 tmpPath: `${tmpLz4File}.tmp`, 577 }); 578 // Decompress using LZ4 579 const changesetsJson = await IOUtils.readUTF8(tmpLz4File, { 580 decompress: true, 581 }); 582 // Parse JSON from string 583 return JSON.parse(changesetsJson); 584 } finally { 585 await IOUtils.remove(tmpLz4File, { ignoreAbsent: true }); 586 } 587 }, 588 };