summaryrefslogtreecommitdiffstats
path: root/browser/modules/SiteDataManager.jsm
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/modules/SiteDataManager.jsm623
1 files changed, 623 insertions, 0 deletions
diff --git a/browser/modules/SiteDataManager.jsm b/browser/modules/SiteDataManager.jsm
new file mode 100644
index 0000000000..f2bc55184a
--- /dev/null
+++ b/browser/modules/SiteDataManager.jsm
@@ -0,0 +1,623 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+var EXPORTED_SYMBOLS = ["SiteDataManager"];
+
+XPCOMUtils.defineLazyGetter(this, "gStringBundle", function() {
+ return Services.strings.createBundle(
+ "chrome://browser/locale/siteData.properties"
+ );
+});
+
+XPCOMUtils.defineLazyGetter(this, "gBrandBundle", function() {
+ return Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+});
+
+var SiteDataManager = {
+ _appCache: Cc["@mozilla.org/network/application-cache-service;1"].getService(
+ Ci.nsIApplicationCacheService
+ ),
+
+ // A Map of sites and their disk usage according to Quota Manager and appcache
+ // Key is host (group sites based on host across scheme, port, origin atttributes).
+ // Value is one object holding:
+ // - principals: instances of nsIPrincipal (only when the site has
+ // quota storage or AppCache).
+ // - persisted: the persistent-storage status.
+ // - quotaUsage: the usage of indexedDB and localStorage.
+ // - appCacheList: an array of app cache; instances of nsIApplicationCache
+ _sites: new Map(),
+
+ _getCacheSizeObserver: null,
+
+ _getCacheSizePromise: null,
+
+ _getQuotaUsagePromise: null,
+
+ _quotaUsageRequest: null,
+
+ /**
+ * Retrieve the latest site data and store it in SiteDataManager.
+ *
+ * Updating site data is a *very* expensive operation. This method exists so that
+ * consumers can manually decide when to update, most methods on SiteDataManager
+ * will not trigger updates automatically.
+ *
+ * It is *highly discouraged* to await on this function to finish before showing UI.
+ * Either trigger the update some time before the data is needed or use the
+ * entryUpdatedCallback parameter to update the UI async.
+ *
+ * @param {entryUpdatedCallback} a function to be called whenever a site is added or
+ * updated. This can be used to e.g. fill a UI that lists sites without
+ * blocking on the entire update to finish.
+ * @returns a Promise that resolves when updating is done.
+ **/
+ async updateSites(entryUpdatedCallback) {
+ Services.obs.notifyObservers(null, "sitedatamanager:updating-sites");
+ // Clear old data and requests first
+ this._sites.clear();
+ this._getAllCookies(entryUpdatedCallback);
+ await this._getQuotaUsage(entryUpdatedCallback);
+ this._updateAppCache(entryUpdatedCallback);
+ Services.obs.notifyObservers(null, "sitedatamanager:sites-updated");
+ },
+
+ getBaseDomainFromHost(host) {
+ let result = host;
+ try {
+ result = Services.eTLD.getBaseDomainFromHost(host);
+ } catch (e) {
+ if (
+ e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS ||
+ e.result == Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS
+ ) {
+ // For these 2 expected errors, just take the host as the result.
+ // - NS_ERROR_HOST_IS_IP_ADDRESS: the host is in ipv4/ipv6.
+ // - NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS: not enough domain parts to extract.
+ result = host;
+ } else {
+ throw e;
+ }
+ }
+ return result;
+ },
+
+ _getOrInsertSite(host) {
+ let site = this._sites.get(host);
+ if (!site) {
+ site = {
+ baseDomain: this.getBaseDomainFromHost(host),
+ cookies: [],
+ persisted: false,
+ quotaUsage: 0,
+ lastAccessed: 0,
+ principals: [],
+ appCacheList: [],
+ };
+ this._sites.set(host, site);
+ }
+ return site;
+ },
+
+ /**
+ * Retrieves the amount of space currently used by disk cache.
+ *
+ * You can use DownloadUtils.convertByteUnits to convert this to
+ * a user-understandable size/unit combination.
+ *
+ * @returns a Promise that resolves with the cache size on disk in bytes.
+ */
+ getCacheSize() {
+ if (this._getCacheSizePromise) {
+ return this._getCacheSizePromise;
+ }
+
+ this._getCacheSizePromise = new Promise((resolve, reject) => {
+ // Needs to root the observer since cache service keeps only a weak reference.
+ this._getCacheSizeObserver = {
+ onNetworkCacheDiskConsumption: consumption => {
+ resolve(consumption);
+ this._getCacheSizePromise = null;
+ this._getCacheSizeObserver = null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsICacheStorageConsumptionObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+
+ try {
+ Services.cache2.asyncGetDiskConsumption(this._getCacheSizeObserver);
+ } catch (e) {
+ reject(e);
+ this._getCacheSizePromise = null;
+ this._getCacheSizeObserver = null;
+ }
+ });
+
+ return this._getCacheSizePromise;
+ },
+
+ _getQuotaUsage(entryUpdatedCallback) {
+ this._cancelGetQuotaUsage();
+ this._getQuotaUsagePromise = new Promise(resolve => {
+ let onUsageResult = request => {
+ if (request.resultCode == Cr.NS_OK) {
+ let items = request.result;
+ for (let item of items) {
+ if (!item.persisted && item.usage <= 0) {
+ // An non-persistent-storage site with 0 byte quota usage is redundant for us so skip it.
+ continue;
+ }
+ let principal = Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ item.origin
+ );
+ if (principal.schemeIs("http") || principal.schemeIs("https")) {
+ let site = this._getOrInsertSite(principal.host);
+ // Assume 3 sites:
+ // - Site A (not persisted): https://www.foo.com
+ // - Site B (not persisted): https://www.foo.com^userContextId=2
+ // - Site C (persisted): https://www.foo.com:1234
+ // Although only C is persisted, grouping by host, as a result,
+ // we still mark as persisted here under this host group.
+ if (item.persisted) {
+ site.persisted = true;
+ }
+ if (site.lastAccessed < item.lastAccessed) {
+ site.lastAccessed = item.lastAccessed;
+ }
+ site.principals.push(principal);
+ site.quotaUsage += item.usage;
+ if (entryUpdatedCallback) {
+ entryUpdatedCallback(principal.host, site);
+ }
+ }
+ }
+ }
+ resolve();
+ };
+ // XXX: The work of integrating localStorage into Quota Manager is in progress.
+ // After the bug 742822 and 1286798 landed, localStorage usage will be included.
+ // So currently only get indexedDB usage.
+ this._quotaUsageRequest = Services.qms.getUsage(onUsageResult);
+ });
+ return this._getQuotaUsagePromise;
+ },
+
+ _getAllCookies(entryUpdatedCallback) {
+ for (let cookie of Services.cookies.cookies) {
+ let site = this._getOrInsertSite(cookie.rawHost);
+ if (entryUpdatedCallback) {
+ entryUpdatedCallback(cookie.rawHost, site);
+ }
+ site.cookies.push(cookie);
+ if (site.lastAccessed < cookie.lastAccessed) {
+ site.lastAccessed = cookie.lastAccessed;
+ }
+ }
+ },
+
+ _cancelGetQuotaUsage() {
+ if (this._quotaUsageRequest) {
+ this._quotaUsageRequest.cancel();
+ this._quotaUsageRequest = null;
+ }
+ },
+
+ _updateAppCache(entryUpdatedCallback) {
+ let groups;
+ try {
+ groups = this._appCache.getGroups();
+ } catch (e) {
+ // NS_ERROR_NOT_AVAILABLE means that appCache is not initialized,
+ // which probably means the user has disabled it. Otherwise, log an
+ // error. Either way, there's nothing we can do here.
+ if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) {
+ Cu.reportError(e);
+ }
+ return;
+ }
+
+ for (let group of groups) {
+ let cache = this._appCache.getActiveCache(group);
+ if (cache.usage <= 0) {
+ // A site with 0 byte appcache usage is redundant for us so skip it.
+ continue;
+ }
+ let principal = Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ group
+ );
+ let site = this._getOrInsertSite(principal.host);
+ if (!site.principals.some(p => p.origin == principal.origin)) {
+ site.principals.push(principal);
+ }
+ site.appCacheList.push(cache);
+ if (entryUpdatedCallback) {
+ entryUpdatedCallback(principal.host, site);
+ }
+ }
+ },
+
+ /**
+ * Gets the current AppCache usage by host. This is using asciiHost to compare
+ * against the provided host.
+ *
+ * @param {String} the ascii host to check usage for
+ * @returns the usage in bytes
+ */
+ getAppCacheUsageByHost(host) {
+ let usage = 0;
+
+ let groups;
+ try {
+ groups = this._appCache.getGroups();
+ } catch (e) {
+ // NS_ERROR_NOT_AVAILABLE means that appCache is not initialized,
+ // which probably means the user has disabled it. Otherwise, log an
+ // error. Either way, there's nothing we can do here.
+ if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) {
+ Cu.reportError(e);
+ }
+ return usage;
+ }
+
+ for (let group of groups) {
+ let uri = Services.io.newURI(group);
+ if (uri.asciiHost == host) {
+ let cache = this._appCache.getActiveCache(group);
+ usage += cache.usage;
+ }
+ }
+
+ return usage;
+ },
+
+ /**
+ * Checks if the site with the provided ASCII host is using any site data at all.
+ * This will check for:
+ * - Cookies (incl. subdomains)
+ * - AppCache
+ * - Quota Usage
+ * in that order. This function is meant to be fast, and thus will
+ * end searching and return true once the first trace of site data is found.
+ *
+ * @param {String} the ASCII host to check
+ * @returns {Boolean} whether the site has any data associated with it
+ */
+ async hasSiteData(asciiHost) {
+ if (Services.cookies.countCookiesFromHost(asciiHost)) {
+ return true;
+ }
+
+ let appCacheUsage = this.getAppCacheUsageByHost(asciiHost);
+ if (appCacheUsage > 0) {
+ return true;
+ }
+
+ let hasQuota = await new Promise(resolve => {
+ Services.qms.getUsage(request => {
+ if (request.resultCode != Cr.NS_OK) {
+ resolve(false);
+ return;
+ }
+
+ for (let item of request.result) {
+ if (!item.persisted && item.usage <= 0) {
+ continue;
+ }
+
+ let principal = Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ item.origin
+ );
+ if (principal.asciiHost == asciiHost) {
+ resolve(true);
+ return;
+ }
+ }
+
+ resolve(false);
+ });
+ });
+
+ if (hasQuota) {
+ return true;
+ }
+
+ return false;
+ },
+
+ getTotalUsage() {
+ return this._getQuotaUsagePromise.then(() => {
+ let usage = 0;
+ for (let site of this._sites.values()) {
+ for (let cache of site.appCacheList) {
+ usage += cache.usage;
+ }
+ usage += site.quotaUsage;
+ }
+ return usage;
+ });
+ },
+
+ /**
+ * Gets all sites that are currently storing site data.
+ *
+ * The list is not automatically up-to-date.
+ * You need to call SiteDataManager.updateSites() before you
+ * can use this method for the first time (and whenever you want
+ * to get an updated set of list.)
+ *
+ * @param {String} [optional] baseDomain - if specified, it will
+ * only return data for sites with
+ * the specified base domain.
+ *
+ * @returns a Promise that resolves with the list of all sites.
+ */
+ getSites(baseDomain) {
+ return this._getQuotaUsagePromise.then(() => {
+ let list = [];
+ for (let [host, site] of this._sites) {
+ if (baseDomain && site.baseDomain != baseDomain) {
+ continue;
+ }
+
+ let usage = site.quotaUsage;
+ for (let cache of site.appCacheList) {
+ usage += cache.usage;
+ }
+ list.push({
+ baseDomain: site.baseDomain,
+ cookies: site.cookies,
+ host,
+ usage,
+ persisted: site.persisted,
+ lastAccessed: new Date(site.lastAccessed / 1000),
+ });
+ }
+ return list;
+ });
+ },
+
+ _removePermission(site) {
+ let removals = new Set();
+ for (let principal of site.principals) {
+ let { originNoSuffix } = principal;
+ if (removals.has(originNoSuffix)) {
+ // In case of encountering
+ // - https://www.foo.com
+ // - https://www.foo.com^userContextId=2
+ // because setting/removing permission is across OAs already so skip the same origin without suffix
+ continue;
+ }
+ removals.add(originNoSuffix);
+ Services.perms.removeFromPrincipal(principal, "persistent-storage");
+ }
+ },
+
+ _removeQuotaUsage(site) {
+ let promises = [];
+ let removals = new Set();
+ for (let principal of site.principals) {
+ let { originNoSuffix } = principal;
+ if (removals.has(originNoSuffix)) {
+ // In case of encountering
+ // - https://www.foo.com
+ // - https://www.foo.com^userContextId=2
+ // below we have already removed across OAs so skip the same origin without suffix
+ continue;
+ }
+ removals.add(originNoSuffix);
+ promises.push(
+ new Promise(resolve => {
+ // We are clearing *All* across OAs so need to ensure a principal without suffix here,
+ // or the call of `clearStoragesForPrincipal` would fail.
+ principal = Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ originNoSuffix
+ );
+ let request = this._qms.clearStoragesForPrincipal(
+ principal,
+ null,
+ null,
+ true
+ );
+ request.callback = resolve;
+ })
+ );
+ }
+ return Promise.all(promises);
+ },
+
+ _removeAppCache(site) {
+ for (let cache of site.appCacheList) {
+ cache.discard();
+ }
+ },
+
+ _removeCookies(site) {
+ for (let cookie of site.cookies) {
+ Services.cookies.remove(
+ cookie.host,
+ cookie.name,
+ cookie.path,
+ cookie.originAttributes
+ );
+ }
+ site.cookies = [];
+ },
+
+ // Returns a list of permissions from the permission manager that
+ // we consider part of "site data and cookies".
+ _getDeletablePermissions() {
+ let perms = [];
+
+ for (let permission of Services.perms.all) {
+ if (
+ permission.type == "persistent-storage" ||
+ permission.type == "storage-access"
+ ) {
+ perms.push(permission);
+ }
+ }
+
+ return perms;
+ },
+
+ /**
+ * Removes all site data for the specified list of hosts.
+ *
+ * @param {Array} a list of hosts to match for removal.
+ * @returns a Promise that resolves when data is removed and the site data
+ * manager has been updated.
+ */
+ async remove(hosts) {
+ let perms = this._getDeletablePermissions();
+ let promises = [];
+ for (let host of hosts) {
+ const kFlags =
+ Ci.nsIClearDataService.CLEAR_COOKIES |
+ Ci.nsIClearDataService.CLEAR_DOM_STORAGES |
+ Ci.nsIClearDataService.CLEAR_SECURITY_SETTINGS |
+ Ci.nsIClearDataService.CLEAR_PLUGIN_DATA |
+ Ci.nsIClearDataService.CLEAR_EME |
+ Ci.nsIClearDataService.CLEAR_ALL_CACHES;
+ promises.push(
+ new Promise(function(resolve) {
+ const { clearData } = Services;
+ if (host) {
+ clearData.deleteDataFromHost(host, true, kFlags, resolve);
+ } else {
+ clearData.deleteDataFromLocalFiles(true, kFlags, resolve);
+ }
+ })
+ );
+
+ for (let perm of perms) {
+ // Specialcase local file permissions.
+ if (!host) {
+ if (perm.principal.schemeIs("file")) {
+ Services.perms.removePermission(perm);
+ }
+ } else if (Services.eTLD.hasRootDomain(perm.principal.host, host)) {
+ Services.perms.removePermission(perm);
+ }
+ }
+ }
+
+ await Promise.all(promises);
+
+ return this.updateSites();
+ },
+
+ /**
+ * In the specified window, shows a prompt for removing
+ * all site data or the specified list of hosts, warning the
+ * user that this may log them out of websites.
+ *
+ * @param {mozIDOMWindowProxy} a parent DOM window to host the dialog.
+ * @param {Array} [optional] an array of host name strings that will be removed.
+ * @param {baseDomain} [optional] a baseDomain to use in the dialog when searching
+ * for hosts to be removed. This will trigger a SiteDataManager update.
+ * @returns a boolean whether the user confirmed the prompt.
+ */
+ promptSiteDataRemoval(win, removals, baseDomain) {
+ if (baseDomain || removals) {
+ let args = {
+ baseDomain,
+ hosts: removals,
+ allowed: false,
+ };
+ let features = "centerscreen,chrome,modal,resizable=no";
+ win.browsingContext.topChromeWindow.openDialog(
+ "chrome://browser/content/preferences/dialogs/siteDataRemoveSelected.xhtml",
+ "",
+ features,
+ args
+ );
+ return args.allowed;
+ }
+
+ let brandName = gBrandBundle.GetStringFromName("brandShortName");
+ let flags =
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 +
+ Services.prompt.BUTTON_POS_0_DEFAULT;
+ let title = gStringBundle.GetStringFromName("clearSiteDataPromptTitle");
+ let text = gStringBundle.formatStringFromName("clearSiteDataPromptText", [
+ brandName,
+ ]);
+ let btn0Label = gStringBundle.GetStringFromName("clearSiteDataNow");
+
+ let result = Services.prompt.confirmEx(
+ win,
+ title,
+ text,
+ flags,
+ btn0Label,
+ null,
+ null,
+ null,
+ {}
+ );
+ return result == 0;
+ },
+
+ /**
+ * Clears all site data and cache
+ *
+ * @returns a Promise that resolves when the data is cleared.
+ */
+ async removeAll() {
+ await this.removeCache();
+ return this.removeSiteData();
+ },
+
+ /**
+ * Clears all caches.
+ *
+ * @returns a Promise that resolves when the data is cleared.
+ */
+ removeCache() {
+ return new Promise(function(resolve) {
+ Services.clearData.deleteData(
+ Ci.nsIClearDataService.CLEAR_ALL_CACHES,
+ resolve
+ );
+ });
+ },
+
+ /**
+ * Clears all site data, but not cache, because the UI offers
+ * that functionality separately.
+ *
+ * @returns a Promise that resolves when the data is cleared.
+ */
+ async removeSiteData() {
+ await new Promise(function(resolve) {
+ Services.clearData.deleteData(
+ Ci.nsIClearDataService.CLEAR_COOKIES |
+ Ci.nsIClearDataService.CLEAR_DOM_STORAGES |
+ Ci.nsIClearDataService.CLEAR_SECURITY_SETTINGS |
+ Ci.nsIClearDataService.CLEAR_EME |
+ Ci.nsIClearDataService.CLEAR_PLUGIN_DATA,
+ resolve
+ );
+ });
+
+ for (let permission of this._getDeletablePermissions()) {
+ Services.perms.removePermission(permission);
+ }
+
+ return this.updateSites();
+ },
+};