Bug 1869043 add a main thread record of track audio outputs r=padenot
[gecko.git] / services / settings / Utils.sys.mjs
blob73c83e526be1a3a252f995d0718e3975d50bffa7
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/. */
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";
9 const lazy = {};
11 ChromeUtils.defineESModuleGetters(lazy, {
12   SharedUtils: "resource://services-settings/SharedUtils.sys.mjs",
13 });
15 XPCOMUtils.defineLazyServiceGetter(
16   lazy,
17   "CaptivePortalService",
18   "@mozilla.org/network/captive-portal-service;1",
19   "nsICaptivePortalService"
21 XPCOMUtils.defineLazyServiceGetter(
22   lazy,
23   "gNetworkLinkService",
24   "@mozilla.org/network/network-link-service;1",
25   "nsINetworkLinkService"
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 })();
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 });
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   }
59   if (lazy.isRunningTests) {
60     return true;
61   }
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   }
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   }
75   return false;
76 });
78 XPCOMUtils.defineLazyPreferenceGetter(
79   lazy,
80   "gServerURL",
81   "services.settings.server",
82   AppConstants.REMOTE_SETTINGS_SERVER_URL
85 XPCOMUtils.defineLazyPreferenceGetter(
86   lazy,
87   "gPreviewEnabled",
88   "services.settings.preview_enabled",
89   false
92 function _isUndefined(value) {
93   return typeof value === "undefined";
96 export var Utils = {
97   get SERVER_URL() {
98     return lazy.allowServerURLOverride
99       ? lazy.gServerURL
100       : AppConstants.REMOTE_SETTINGS_SERVER_URL;
101   },
103   CHANGES_PATH: "/buckets/monitor/collections/changes/changeset",
105   /**
106    * Logger instance.
107    */
108   log,
110   get shouldSkipRemoteActivityDueToTests() {
111     return (
112       (lazy.isRunningTests || Cu.isInAutomation) &&
113       this.SERVER_URL == "data:,#remote-settings-dummy/v1"
114     );
115   },
117   get CERT_CHAIN_ROOT_IDENTIFIER() {
118     if (this.SERVER_URL == AppConstants.REMOTE_SETTINGS_SERVER_URL) {
119       return Ci.nsIContentSignatureVerifier.ContentSignatureProdRoot;
120     }
121     if (this.SERVER_URL.includes("allizom.")) {
122       return Ci.nsIContentSignatureVerifier.ContentSignatureStageRoot;
123     }
124     if (this.SERVER_URL.includes("dev.")) {
125       return Ci.nsIContentSignatureVerifier.ContentSignatureDevRoot;
126     }
127     if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
128       return Ci.nsIX509CertDB.AppXPCShellRoot;
129     }
130     return Ci.nsIContentSignatureVerifier.ContentSignatureLocalRoot;
131   },
133   get LOAD_DUMPS() {
134     // Load dumps only if pulling data from the production server, or in tests.
135     return (
136       this.SERVER_URL == AppConstants.REMOTE_SETTINGS_SERVER_URL ||
137       lazy.isRunningTests
138     );
139   },
141   get PREVIEW_MODE() {
142     // We want to offer the ability to set preview mode via a preference
143     // for consumers who want to pull from the preview bucket on startup.
144     if (_isUndefined(this._previewModeEnabled) && lazy.allowServerURLOverride) {
145       return lazy.gPreviewEnabled;
146     }
147     return !!this._previewModeEnabled;
148   },
150   /**
151    * Internal method to enable pulling data from preview buckets.
152    * @param enabled
153    */
154   enablePreviewMode(enabled) {
155     const bool2str = v =>
156       // eslint-disable-next-line no-nested-ternary
157       _isUndefined(v) ? "unset" : v ? "enabled" : "disabled";
158     this.log.debug(
159       `Preview mode: ${bool2str(this._previewModeEnabled)} -> ${bool2str(
160         enabled
161       )}`
162     );
163     this._previewModeEnabled = enabled;
164   },
166   /**
167    * Returns the actual bucket name to be used. When preview mode is enabled,
168    * this adds the *preview* suffix.
169    *
170    * See also `SharedUtils.loadJSONDump()` which strips the preview suffix to identify
171    * the packaged JSON file.
172    *
173    * @param bucketName the client bucket
174    * @returns the final client bucket depending whether preview mode is enabled.
175    */
176   actualBucketName(bucketName) {
177     let actual = bucketName.replace("-preview", "");
178     if (this.PREVIEW_MODE) {
179       actual += "-preview";
180     }
181     return actual;
182   },
184   /**
185    * Check if network is down.
186    *
187    * Note that if this returns false, it does not guarantee
188    * that network is up.
189    *
190    * @return {bool} Whether network is down or not.
191    */
192   get isOffline() {
193     try {
194       return (
195         Services.io.offline ||
196         lazy.CaptivePortalService.state ==
197           lazy.CaptivePortalService.LOCKED_PORTAL ||
198         !lazy.gNetworkLinkService.isLinkUp
199       );
200     } catch (ex) {
201       log.warn("Could not determine network status.", ex);
202     }
203     return false;
204   },
206   /**
207    * A wrapper around `ServiceRequest` that behaves like `fetch()`.
208    *
209    * Use this in order to leverage the `beConservative` flag, for
210    * example to avoid using HTTP3 to fetch critical data.
211    *
212    * @param input a resource
213    * @param init request options
214    * @returns a Response object
215    */
216   async fetch(input, init = {}) {
217     return new Promise(function (resolve, reject) {
218       const request = new ServiceRequest();
219       function fallbackOrReject(err) {
220         if (
221           // At most one recursive Utils.fetch call (bypassProxy=false to true).
222           bypassProxy ||
223           Services.startup.shuttingDown ||
224           Utils.isOffline ||
225           !request.isProxied ||
226           !request.bypassProxyEnabled
227         ) {
228           reject(err);
229           return;
230         }
231         ServiceRequest.logProxySource(request.channel, "remote-settings");
232         resolve(Utils.fetch(input, { ...init, bypassProxy: true }));
233       }
235       request.onerror = () =>
236         fallbackOrReject(new TypeError("NetworkError: Network request failed"));
237       request.ontimeout = () =>
238         fallbackOrReject(new TypeError("Timeout: Network request failed"));
239       request.onabort = () =>
240         fallbackOrReject(new DOMException("Aborted", "AbortError"));
241       request.onload = () => {
242         // Parse raw response headers into `Headers` object.
243         const headers = new Headers();
244         const rawHeaders = request.getAllResponseHeaders();
245         rawHeaders
246           .trim()
247           .split(/[\r\n]+/)
248           .forEach(line => {
249             const parts = line.split(": ");
250             const header = parts.shift();
251             const value = parts.join(": ");
252             headers.set(header, value);
253           });
255         const responseAttributes = {
256           status: request.status,
257           statusText: request.statusText,
258           url: request.responseURL,
259           headers,
260         };
261         resolve(new Response(request.response, responseAttributes));
262       };
264       const { method = "GET", headers = {}, bypassProxy = false } = init;
266       request.open(method, input, { bypassProxy });
267       // By default, XMLHttpRequest converts the response based on the
268       // Content-Type header, or UTF-8 otherwise. This may mangle binary
269       // responses. Avoid that by requesting the raw bytes.
270       request.responseType = "arraybuffer";
272       for (const [name, value] of Object.entries(headers)) {
273         request.setRequestHeader(name, value);
274       }
276       request.send();
277     });
278   },
280   /**
281    * Check if local data exist for the specified client.
282    *
283    * @param {RemoteSettingsClient} client
284    * @return {bool} Whether it exists or not.
285    */
286   async hasLocalData(client) {
287     const timestamp = await client.db.getLastModified();
288     return timestamp !== null;
289   },
291   /**
292    * Check if we ship a JSON dump for the specified bucket and collection.
293    *
294    * @param {String} bucket
295    * @param {String} collection
296    * @return {bool} Whether it is present or not.
297    */
298   async hasLocalDump(bucket, collection) {
299     try {
300       await fetch(
301         `resource://app/defaults/settings/${bucket}/${collection}.json`,
302         {
303           method: "HEAD",
304         }
305       );
306       return true;
307     } catch (e) {
308       return false;
309     }
310   },
312   /**
313    * Look up the last modification time of the JSON dump.
314    *
315    * @param {String} bucket
316    * @param {String} collection
317    * @return {int} The last modification time of the dump. -1 if non-existent.
318    */
319   async getLocalDumpLastModified(bucket, collection) {
320     if (!this._dumpStats) {
321       if (!this._dumpStatsInitPromise) {
322         this._dumpStatsInitPromise = (async () => {
323           try {
324             let res = await fetch(
325               "resource://app/defaults/settings/last_modified.json"
326             );
327             this._dumpStats = await res.json();
328           } catch (e) {
329             log.warn(`Failed to load last_modified.json: ${e}`);
330             this._dumpStats = {};
331           }
332           delete this._dumpStatsInitPromise;
333         })();
334       }
335       await this._dumpStatsInitPromise;
336     }
337     const identifier = `${bucket}/${collection}`;
338     let lastModified = this._dumpStats[identifier];
339     if (lastModified === undefined) {
340       const { timestamp: dumpTimestamp } = await lazy.SharedUtils.loadJSONDump(
341         bucket,
342         collection
343       );
344       // Client recognize -1 as missing dump.
345       lastModified = dumpTimestamp ?? -1;
346       this._dumpStats[identifier] = lastModified;
347     }
348     return lastModified;
349   },
351   /**
352    * Fetch the list of remote collections and their timestamp.
353    * ```
354    *   {
355    *     "timestamp": 1486545678,
356    *     "changes":[
357    *       {
358    *         "host":"kinto-ota.dev.mozaws.net",
359    *         "last_modified":1450717104423,
360    *         "bucket":"blocklists",
361    *         "collection":"certificates"
362    *       },
363    *       ...
364    *     ],
365    *     "metadata": {}
366    *   }
367    * ```
368    * @param {String} serverUrl         The server URL (eg. `https://server.org/v1`)
369    * @param {int}    expectedTimestamp The timestamp that the server is supposed to return.
370    *                                   We obtained it from the Megaphone notification payload,
371    *                                   and we use it only for cache busting (Bug 1497159).
372    * @param {String} lastEtag          (optional) The Etag of the latest poll to be matched
373    *                                   by the server (eg. `"123456789"`).
374    * @param {Object} filters
375    */
376   async fetchLatestChanges(serverUrl, options = {}) {
377     const { expectedTimestamp, lastEtag = "", filters = {} } = options;
379     let url = serverUrl + Utils.CHANGES_PATH;
380     const params = {
381       ...filters,
382       _expected: expectedTimestamp ?? 0,
383     };
384     if (lastEtag != "") {
385       params._since = lastEtag;
386     }
387     if (params) {
388       url +=
389         "?" +
390         Object.entries(params)
391           .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
392           .join("&");
393     }
394     const response = await Utils.fetch(url);
396     if (response.status >= 500) {
397       throw new Error(`Server error ${response.status} ${response.statusText}`);
398     }
400     const is404FromCustomServer =
401       response.status == 404 &&
402       Services.prefs.prefHasUserValue("services.settings.server");
404     const ct = response.headers.get("Content-Type");
405     if (!is404FromCustomServer && (!ct || !ct.includes("application/json"))) {
406       throw new Error(`Unexpected content-type "${ct}"`);
407     }
409     let payload;
410     try {
411       payload = await response.json();
412     } catch (e) {
413       payload = e.message;
414     }
416     if (!payload.hasOwnProperty("changes")) {
417       // If the server is failing, the JSON response might not contain the
418       // expected data. For example, real server errors (Bug 1259145)
419       // or dummy local server for tests (Bug 1481348)
420       if (!is404FromCustomServer) {
421         throw new Error(
422           `Server error ${url} ${response.status} ${
423             response.statusText
424           }: ${JSON.stringify(payload)}`
425         );
426       }
427     }
429     const { changes = [], timestamp } = payload;
431     let serverTimeMillis = Date.parse(response.headers.get("Date"));
432     // Since the response is served via a CDN, the Date header value could have been cached.
433     const cacheAgeSeconds = response.headers.has("Age")
434       ? parseInt(response.headers.get("Age"), 10)
435       : 0;
436     serverTimeMillis += cacheAgeSeconds * 1000;
438     // Age of data (time between publication and now).
439     const ageSeconds = (serverTimeMillis - timestamp) / 1000;
441     // Check if the server asked the clients to back off.
442     let backoffSeconds;
443     if (response.headers.has("Backoff")) {
444       const value = parseInt(response.headers.get("Backoff"), 10);
445       if (!isNaN(value)) {
446         backoffSeconds = value;
447       }
448     }
450     return {
451       changes,
452       currentEtag: `"${timestamp}"`,
453       serverTimeMillis,
454       backoffSeconds,
455       ageSeconds,
456     };
457   },
459   /**
460    * Test if a single object matches all given filters.
461    *
462    * @param  {Object} filters  The filters object.
463    * @param  {Object} entry    The object to filter.
464    * @return {Boolean}
465    */
466   filterObject(filters, entry) {
467     return Object.entries(filters).every(([filter, value]) => {
468       if (Array.isArray(value)) {
469         return value.some(candidate => candidate === entry[filter]);
470       } else if (typeof value === "object") {
471         return Utils.filterObject(value, entry[filter]);
472       } else if (!Object.prototype.hasOwnProperty.call(entry, filter)) {
473         console.error(`The property ${filter} does not exist`);
474         return false;
475       }
476       return entry[filter] === value;
477     });
478   },
480   /**
481    * Sorts records in a list according to a given ordering.
482    *
483    * @param  {String} order The ordering, eg. `-last_modified`.
484    * @param  {Array}  list  The collection to order.
485    * @return {Array}
486    */
487   sortObjects(order, list) {
488     const hasDash = order[0] === "-";
489     const field = hasDash ? order.slice(1) : order;
490     const direction = hasDash ? -1 : 1;
491     return list.slice().sort((a, b) => {
492       if (a[field] && _isUndefined(b[field])) {
493         return direction;
494       }
495       if (b[field] && _isUndefined(a[field])) {
496         return -direction;
497       }
498       if (_isUndefined(a[field]) && _isUndefined(b[field])) {
499         return 0;
500       }
501       return a[field] > b[field] ? direction : -direction;
502     });
503   },