Bug 1890689 Don't pretend to pre-buffer with DynamicResampler r=pehrsons
[gecko.git] / browser / modules / SiteDataManager.sys.mjs
blob082d250ddc2126ee1b462592d90b0e5e3de50deb
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 const lazy = {};
7 ChromeUtils.defineLazyGetter(lazy, "gStringBundle", function () {
8   return Services.strings.createBundle(
9     "chrome://browser/locale/siteData.properties"
10   );
11 });
13 ChromeUtils.defineLazyGetter(lazy, "gBrandBundle", function () {
14   return Services.strings.createBundle(
15     "chrome://branding/locale/brand.properties"
16   );
17 });
19 ChromeUtils.defineESModuleGetters(lazy, {
20   Sanitizer: "resource:///modules/Sanitizer.sys.mjs",
21 });
23 export var SiteDataManager = {
24   // A Map of sites and their disk usage according to Quota Manager.
25   // Key is base domain (group sites based on base domain across scheme, port,
26   // origin attributes) or host if the entry does not have a base domain.
27   // Value is one object holding:
28   //   - baseDomainOrHost: Same as key.
29   //   - principals: instances of nsIPrincipal (only when the site has
30   //     quota storage).
31   //   - persisted: the persistent-storage status.
32   //   - quotaUsage: the usage of indexedDB and localStorage.
33   //   - containersData: a map containing cookiesBlocked,lastAccessed and quotaUsage by userContextID.
34   _sites: new Map(),
36   _getCacheSizeObserver: null,
38   _getCacheSizePromise: null,
40   _getQuotaUsagePromise: null,
42   _quotaUsageRequest: null,
44   /**
45    *  Retrieve the latest site data and store it in SiteDataManager.
46    *
47    *  Updating site data is a *very* expensive operation. This method exists so that
48    *  consumers can manually decide when to update, most methods on SiteDataManager
49    *  will not trigger updates automatically.
50    *
51    *  It is *highly discouraged* to await on this function to finish before showing UI.
52    *  Either trigger the update some time before the data is needed or use the
53    *  entryUpdatedCallback parameter to update the UI async.
54    *
55    * @param {entryUpdatedCallback} a function to be called whenever a site is added or
56    *        updated. This can be used to e.g. fill a UI that lists sites without
57    *        blocking on the entire update to finish.
58    * @returns a Promise that resolves when updating is done.
59    **/
60   async updateSites(entryUpdatedCallback) {
61     Services.obs.notifyObservers(null, "sitedatamanager:updating-sites");
62     // Clear old data and requests first
63     this._sites.clear();
64     this._getAllCookies(entryUpdatedCallback);
65     await this._getQuotaUsage(entryUpdatedCallback);
66     Services.obs.notifyObservers(null, "sitedatamanager:sites-updated");
67   },
69   /**
70    * Get the base domain of a host on a best-effort basis.
71    * @param {string} host - Host to convert.
72    * @returns {string} Computed base domain. If the base domain cannot be
73    * determined, because the host is an IP address or does not have enough
74    * domain levels we will return the original host. This includes the empty
75    * string.
76    * @throws {Error} Throws for unexpected conversion errors from eTLD service.
77    */
78   getBaseDomainFromHost(host) {
79     let result = host;
80     try {
81       result = Services.eTLD.getBaseDomainFromHost(host);
82     } catch (e) {
83       if (
84         e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS ||
85         e.result == Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS
86       ) {
87         // For these 2 expected errors, just take the host as the result.
88         // - NS_ERROR_HOST_IS_IP_ADDRESS: the host is in ipv4/ipv6.
89         // - NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS: not enough domain parts to extract.
90         result = host;
91       } else {
92         throw e;
93       }
94     }
95     return result;
96   },
98   _getOrInsertSite(baseDomainOrHost) {
99     let site = this._sites.get(baseDomainOrHost);
100     if (!site) {
101       site = {
102         baseDomainOrHost,
103         cookies: [],
104         persisted: false,
105         quotaUsage: 0,
106         lastAccessed: 0,
107         principals: [],
108       };
109       this._sites.set(baseDomainOrHost, site);
110     }
111     return site;
112   },
114   /**
115    * Insert site with specific params into the SiteDataManager
116    * Currently used for testing purposes
117    *
118    * @param {String} baseDomainOrHost
119    * @param {Object} Site info params
120    * @returns {Object} site object
121    */
122   _testInsertSite(
123     baseDomainOrHost,
124     {
125       cookies = [],
126       persisted = false,
127       quotaUsage = 0,
128       lastAccessed = 0,
129       principals = [],
130     }
131   ) {
132     let site = {
133       baseDomainOrHost,
134       cookies,
135       persisted,
136       quotaUsage,
137       lastAccessed,
138       principals,
139     };
140     this._sites.set(baseDomainOrHost, site);
142     return site;
143   },
145   _getOrInsertContainersData(site, userContextId) {
146     if (!site.containersData) {
147       site.containersData = new Map();
148     }
150     let containerData = site.containersData.get(userContextId);
151     if (!containerData) {
152       containerData = {
153         cookiesBlocked: 0,
154         lastAccessed: new Date(0),
155         quotaUsage: 0,
156       };
157       site.containersData.set(userContextId, containerData);
158     }
159     return containerData;
160   },
162   /**
163    * Retrieves the amount of space currently used by disk cache.
164    *
165    * You can use DownloadUtils.convertByteUnits to convert this to
166    * a user-understandable size/unit combination.
167    *
168    * @returns a Promise that resolves with the cache size on disk in bytes.
169    */
170   getCacheSize() {
171     if (this._getCacheSizePromise) {
172       return this._getCacheSizePromise;
173     }
175     this._getCacheSizePromise = new Promise((resolve, reject) => {
176       // Needs to root the observer since cache service keeps only a weak reference.
177       this._getCacheSizeObserver = {
178         onNetworkCacheDiskConsumption: consumption => {
179           resolve(consumption);
180           this._getCacheSizePromise = null;
181           this._getCacheSizeObserver = null;
182         },
184         QueryInterface: ChromeUtils.generateQI([
185           "nsICacheStorageConsumptionObserver",
186           "nsISupportsWeakReference",
187         ]),
188       };
190       try {
191         Services.cache2.asyncGetDiskConsumption(this._getCacheSizeObserver);
192       } catch (e) {
193         reject(e);
194         this._getCacheSizePromise = null;
195         this._getCacheSizeObserver = null;
196       }
197     });
199     return this._getCacheSizePromise;
200   },
202   _getQuotaUsage(entryUpdatedCallback) {
203     this._cancelGetQuotaUsage();
204     this._getQuotaUsagePromise = new Promise(resolve => {
205       let onUsageResult = request => {
206         if (request.resultCode == Cr.NS_OK) {
207           let items = request.result;
208           for (let item of items) {
209             if (!item.persisted && item.usage <= 0) {
210               // An non-persistent-storage site with 0 byte quota usage is redundant for us so skip it.
211               continue;
212             }
213             let principal =
214               Services.scriptSecurityManager.createContentPrincipalFromOrigin(
215                 item.origin
216               );
217             if (principal.schemeIs("http") || principal.schemeIs("https")) {
218               // Group dom storage by first party. If an entry is partitioned
219               // the first party site will be in the partitionKey, instead of
220               // the principal baseDomain.
221               let pkBaseDomain;
222               try {
223                 pkBaseDomain = ChromeUtils.getBaseDomainFromPartitionKey(
224                   principal.originAttributes.partitionKey
225                 );
226               } catch (e) {
227                 console.error(e);
228               }
229               let site = this._getOrInsertSite(
230                 pkBaseDomain || principal.baseDomain
231               );
232               // Assume 3 sites:
233               //   - Site A (not persisted): https://www.foo.com
234               //   - Site B (not persisted): https://www.foo.com^userContextId=2
235               //   - Site C (persisted):     https://www.foo.com:1234
236               //     Although only C is persisted, grouping by base domain, as a
237               //     result, we still mark as persisted here under this base
238               //     domain group.
239               if (item.persisted) {
240                 site.persisted = true;
241               }
242               if (site.lastAccessed < item.lastAccessed) {
243                 site.lastAccessed = item.lastAccessed;
244               }
245               if (Number.isInteger(principal.userContextId)) {
246                 let containerData = this._getOrInsertContainersData(
247                   site,
248                   principal.userContextId
249                 );
250                 containerData.quotaUsage = item.usage;
251                 let itemTime = item.lastAccessed / 1000;
252                 if (containerData.lastAccessed.getTime() < itemTime) {
253                   containerData.lastAccessed.setTime(itemTime);
254                 }
255               }
256               site.principals.push(principal);
257               site.quotaUsage += item.usage;
258               if (entryUpdatedCallback) {
259                 entryUpdatedCallback(principal.baseDomain, site);
260               }
261             }
262           }
263         }
264         resolve();
265       };
266       // XXX: The work of integrating localStorage into Quota Manager is in progress.
267       //      After the bug 742822 and 1286798 landed, localStorage usage will be included.
268       //      So currently only get indexedDB usage.
269       this._quotaUsageRequest = Services.qms.getUsage(onUsageResult);
270     });
271     return this._getQuotaUsagePromise;
272   },
274   _getAllCookies(entryUpdatedCallback) {
275     for (let cookie of Services.cookies.cookies) {
276       // Group cookies by first party. If a cookie is partitioned the
277       // partitionKey will contain the first party site, instead of the host
278       // field.
279       let pkBaseDomain;
280       try {
281         pkBaseDomain = ChromeUtils.getBaseDomainFromPartitionKey(
282           cookie.originAttributes.partitionKey
283         );
284       } catch (e) {
285         console.error(e);
286       }
287       let baseDomainOrHost =
288         pkBaseDomain || this.getBaseDomainFromHost(cookie.rawHost);
289       let site = this._getOrInsertSite(baseDomainOrHost);
290       if (entryUpdatedCallback) {
291         entryUpdatedCallback(baseDomainOrHost, site);
292       }
293       site.cookies.push(cookie);
294       if (Number.isInteger(cookie.originAttributes.userContextId)) {
295         let containerData = this._getOrInsertContainersData(
296           site,
297           cookie.originAttributes.userContextId
298         );
299         containerData.cookiesBlocked += 1;
300         let cookieTime = cookie.lastAccessed / 1000;
301         if (containerData.lastAccessed.getTime() < cookieTime) {
302           containerData.lastAccessed.setTime(cookieTime);
303         }
304       }
305       if (site.lastAccessed < cookie.lastAccessed) {
306         site.lastAccessed = cookie.lastAccessed;
307       }
308     }
309   },
311   _cancelGetQuotaUsage() {
312     if (this._quotaUsageRequest) {
313       this._quotaUsageRequest.cancel();
314       this._quotaUsageRequest = null;
315     }
316   },
318   /**
319    * Checks if the site with the provided ASCII host is using any site data at all.
320    * This will check for:
321    *   - Cookies (incl. subdomains)
322    *   - Quota Usage
323    * in that order. This function is meant to be fast, and thus will
324    * end searching and return true once the first trace of site data is found.
325    *
326    * @param {String} the ASCII host to check
327    * @returns {Boolean} whether the site has any data associated with it
328    */
329   async hasSiteData(asciiHost) {
330     if (Services.cookies.countCookiesFromHost(asciiHost)) {
331       return true;
332     }
334     let hasQuota = await new Promise(resolve => {
335       Services.qms.getUsage(request => {
336         if (request.resultCode != Cr.NS_OK) {
337           resolve(false);
338           return;
339         }
341         for (let item of request.result) {
342           if (!item.persisted && item.usage <= 0) {
343             continue;
344           }
346           let principal =
347             Services.scriptSecurityManager.createContentPrincipalFromOrigin(
348               item.origin
349             );
350           if (principal.asciiHost == asciiHost) {
351             resolve(true);
352             return;
353           }
354         }
356         resolve(false);
357       });
358     });
360     if (hasQuota) {
361       return true;
362     }
364     return false;
365   },
367   /**
368    * Fetches total quota usage
369    * This method assumes that siteDataManager.updateSites has been called externally
370    *
371    * @returns total quota usage
372    */
373   getTotalUsage() {
374     return this._getQuotaUsagePromise.then(() => {
375       let usage = 0;
376       for (let site of this._sites.values()) {
377         usage += site.quotaUsage;
378       }
379       return usage;
380     });
381   },
383   /**
384    *
385    * Fetch quota usage for all time ranges to display in the clear data dialog.
386    * This method assumes that SiteDataManager.updateSites has been called externally
387    *
388    * @param {string[]} timeSpanArr - Array of timespan options to get quota usage
389    *              from Sanitizer, e.g. ["TIMESPAN_HOUR", "TIMESPAN_2HOURS"]
390    * @returns {Object} bytes used for each timespan
391    */
392   async getQuotaUsageForTimeRanges(timeSpanArr) {
393     let usage = {};
394     await this._getQuotaUsagePromise;
396     for (let timespan of timeSpanArr) {
397       usage[timespan] = 0;
398     }
400     let timeNow = Date.now();
401     for (let site of this._sites.values()) {
402       let lastAccessed = new Date(site.lastAccessed / 1000);
403       for (let timeSpan of timeSpanArr) {
404         let compareTime = new Date(
405           timeNow - lazy.Sanitizer.timeSpanMsMap[timeSpan]
406         );
408         if (timeSpan === "TIMESPAN_EVERYTHING") {
409           usage[timeSpan] += site.quotaUsage;
410         } else if (lastAccessed >= compareTime) {
411           usage[timeSpan] += site.quotaUsage;
412         }
413       }
414     }
415     return usage;
416   },
418   /**
419    * Gets all sites that are currently storing site data. Entries are grouped by
420    * parent base domain if applicable. For example "foo.example.com",
421    * "example.com" and "bar.example.com" will have one entry with the baseDomain
422    * "example.com".
423    * A base domain entry will represent all data of its storage jar. The storage
424    * jar holds all first party data of the domain as well as any third party
425    * data partitioned under the domain. Additionally we will add data which
426    * belongs to the domain but is part of other domains storage jars . That is
427    * data third-party partitioned under other domains.
428    * Sites which cannot be associated with a base domain, for example IP hosts,
429    * are not grouped.
430    *
431    * The list is not automatically up-to-date. You need to call
432    * {@link updateSites} before you can use this method for the first time (and
433    * whenever you want to get an updated set of list.)
434    *
435    * @returns {Promise} Promise that resolves with the list of all sites.
436    */
437   async getSites() {
438     await this._getQuotaUsagePromise;
440     return Array.from(this._sites.values()).map(site => ({
441       baseDomain: site.baseDomainOrHost,
442       cookies: site.cookies,
443       usage: site.quotaUsage,
444       containersData: site.containersData,
445       persisted: site.persisted,
446       lastAccessed: new Date(site.lastAccessed / 1000),
447     }));
448   },
450   /**
451    * Get site, which stores data, by base domain or host.
452    *
453    * The list is not automatically up-to-date. You need to call
454    * {@link updateSites} before you can use this method for the first time (and
455    * whenever you want to get an updated set of list.)
456    *
457    * @param {String} baseDomainOrHost - Base domain or host of the site to get.
458    *
459    * @returns {Promise<Object|null>} Promise that resolves with the site object
460    * or null if no site with given base domain or host stores data.
461    */
462   async getSite(baseDomainOrHost) {
463     let baseDomain = this.getBaseDomainFromHost(baseDomainOrHost);
465     let site = this._sites.get(baseDomain);
466     if (!site) {
467       return null;
468     }
469     return {
470       baseDomain: site.baseDomainOrHost,
471       cookies: site.cookies,
472       usage: site.quotaUsage,
473       containersData: site.containersData,
474       persisted: site.persisted,
475       lastAccessed: new Date(site.lastAccessed / 1000),
476     };
477   },
479   _removePermission(site) {
480     let removals = new Set();
481     for (let principal of site.principals) {
482       let { originNoSuffix } = principal;
483       if (removals.has(originNoSuffix)) {
484         // In case of encountering
485         //   - https://www.foo.com
486         //   - https://www.foo.com^userContextId=2
487         // because setting/removing permission is across OAs already so skip the same origin without suffix
488         continue;
489       }
490       removals.add(originNoSuffix);
491       Services.perms.removeFromPrincipal(principal, "persistent-storage");
492     }
493   },
495   _removeCookies(site) {
496     for (let cookie of site.cookies) {
497       Services.cookies.remove(
498         cookie.host,
499         cookie.name,
500         cookie.path,
501         cookie.originAttributes
502       );
503     }
504     site.cookies = [];
505   },
507   /**
508    * Removes all site data for the specified list of domains and hosts.
509    * This includes site data of subdomains belonging to the domains or hosts and
510    * partitioned storage. Data is cleared per storage jar, which means if we
511    * clear "example.com", we will also clear third parties embedded on
512    * "example.com". Additionally we will clear all data of "example.com" (as a
513    * third party) from other jars.
514    *
515    * @param {string|string[]} domainsOrHosts - List of domains and hosts or
516    * single domain or host to remove.
517    * @returns {Promise} Promise that resolves when data is removed and the site
518    * data manager has been updated.
519    */
520   async remove(domainsOrHosts) {
521     if (domainsOrHosts == null) {
522       throw new Error("domainsOrHosts is required.");
523     }
524     // Allow the caller to pass a single base domain or host.
525     if (!Array.isArray(domainsOrHosts)) {
526       domainsOrHosts = [domainsOrHosts];
527     }
529     let promises = [];
530     for (let domainOrHost of domainsOrHosts) {
531       const kFlags =
532         Ci.nsIClearDataService.CLEAR_COOKIES |
533         Ci.nsIClearDataService.CLEAR_DOM_STORAGES |
534         Ci.nsIClearDataService.CLEAR_EME |
535         Ci.nsIClearDataService.CLEAR_ALL_CACHES |
536         Ci.nsIClearDataService.CLEAR_COOKIE_BANNER_EXECUTED_RECORD |
537         Ci.nsIClearDataService.CLEAR_FINGERPRINTING_PROTECTION_STATE |
538         Ci.nsIClearDataService.CLEAR_BOUNCE_TRACKING_PROTECTION_STATE |
539         Ci.nsIClearDataService.CLEAR_STORAGE_PERMISSIONS;
540       promises.push(
541         new Promise(function (resolve) {
542           const { clearData } = Services;
543           if (domainOrHost) {
544             // First try to clear by base domain for aDomainOrHost. If we can't
545             // get a base domain, fall back to clearing by just host.
546             try {
547               clearData.deleteDataFromBaseDomain(
548                 domainOrHost,
549                 true,
550                 kFlags,
551                 resolve
552               );
553             } catch (e) {
554               if (
555                 e.result != Cr.NS_ERROR_HOST_IS_IP_ADDRESS &&
556                 e.result != Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS
557               ) {
558                 throw e;
559               }
560               clearData.deleteDataFromHost(domainOrHost, true, kFlags, resolve);
561             }
562           } else {
563             clearData.deleteDataFromLocalFiles(true, kFlags, resolve);
564           }
565         })
566       );
567     }
569     await Promise.all(promises);
571     return this.updateSites();
572   },
574   /**
575    * In the specified window, shows a prompt for removing all site data or the
576    * specified list of base domains or hosts, warning the user that this may log
577    * them out of websites.
578    *
579    * @param {mozIDOMWindowProxy} win - a parent DOM window to host the dialog.
580    * @param {string[]} [removals] - an array of base domain or host strings that
581    * will be removed.
582    * @returns {boolean} whether the user confirmed the prompt.
583    */
584   promptSiteDataRemoval(win, removals) {
585     if (removals) {
586       let args = {
587         hosts: removals,
588         allowed: false,
589       };
590       let features = "centerscreen,chrome,modal,resizable=no";
591       win.browsingContext.topChromeWindow.openDialog(
592         "chrome://browser/content/preferences/dialogs/siteDataRemoveSelected.xhtml",
593         "",
594         features,
595         args
596       );
597       return args.allowed;
598     }
600     let brandName = lazy.gBrandBundle.GetStringFromName("brandShortName");
601     let flags =
602       Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
603       Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 +
604       Services.prompt.BUTTON_POS_0_DEFAULT;
605     let title = lazy.gStringBundle.GetStringFromName(
606       "clearSiteDataPromptTitle"
607     );
608     let text = lazy.gStringBundle.formatStringFromName(
609       "clearSiteDataPromptText",
610       [brandName]
611     );
612     let btn0Label = lazy.gStringBundle.GetStringFromName("clearSiteDataNow");
614     let result = Services.prompt.confirmEx(
615       win,
616       title,
617       text,
618       flags,
619       btn0Label,
620       null,
621       null,
622       null,
623       {}
624     );
625     return result == 0;
626   },
628   /**
629    * Clears all site data and cache
630    *
631    * @returns a Promise that resolves when the data is cleared.
632    */
633   async removeAll() {
634     await this.removeCache();
635     return this.removeSiteData();
636   },
638   /**
639    * Clears all caches.
640    *
641    * @returns a Promise that resolves when the data is cleared.
642    */
643   removeCache() {
644     return new Promise(function (resolve) {
645       Services.clearData.deleteData(
646         Ci.nsIClearDataService.CLEAR_ALL_CACHES,
647         resolve
648       );
649     });
650   },
652   /**
653    * Clears all site data, but not cache, because the UI offers
654    * that functionality separately.
655    *
656    * @returns a Promise that resolves when the data is cleared.
657    */
658   async removeSiteData() {
659     await new Promise(function (resolve) {
660       Services.clearData.deleteData(
661         Ci.nsIClearDataService.CLEAR_COOKIES_AND_SITE_DATA,
662         resolve
663       );
664     });
666     return this.updateSites();
667   },