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