diff options
Diffstat (limited to '')
-rw-r--r-- | browser/base/content/browser-siteProtections.js | 2644 |
1 files changed, 2644 insertions, 0 deletions
diff --git a/browser/base/content/browser-siteProtections.js b/browser/base/content/browser-siteProtections.js new file mode 100644 index 0000000000..7b3b0a12a7 --- /dev/null +++ b/browser/base/content/browser-siteProtections.js @@ -0,0 +1,2644 @@ +/* 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/. */ + +/* eslint-env mozilla/browser-window */ + +ChromeUtils.defineESModuleGetters(this, { + ContentBlockingAllowList: + "resource://gre/modules/ContentBlockingAllowList.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + ToolbarPanelHub: "resource://activity-stream/lib/ToolbarPanelHub.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "TrackingDBService", + "@mozilla.org/tracking-db-service;1", + "nsITrackingDBService" +); + +/** + * Represents a protection category shown in the protections UI. For the most + * common categories we can directly instantiate this category. Some protections + * categories inherit from this class and overwrite some of its members. + */ +class ProtectionCategory { + /** + * Creates a protection category. + * @param {string} id - Identifier of the category. Used to query the category + * UI elements in the DOM. + * @param {Object} options - Category options. + * @param {string} options.prefEnabled - ID of pref which controls the + * category enabled state. + * @param {string} [options.reportBreakageLabel] - Telemetry label to use when + * users report TP breakage. Defaults to protection ID. + * @param {string} [options.l10nId] - Identifier l10n strings are keyed under + * for this category. Defaults to protection ID. + * @param {Object} flags - Flags for this category to look for in the content + * blocking event and content blocking log. + * @param {Number} [flags.load] - Load flag for this protection category. If + * omitted, we will never match a isAllowing check for this category. + * @param {Number} [flags.block] - Block flag for this protection category. If + * omitted, we will never match a isBlocking check for this category. + * @param {Number} [flags.shim] - Shim flag for this protection category. This + * flag is set if we replaced tracking content with a non-tracking shim + * script. + * @param {Number} [flags.allow] - Allow flag for this protection category. + * This flag is set if we explicitly allow normally blocked tracking content. + * The webcompat extension can do this if it needs to unblock content on user + * opt-in. + */ + constructor( + id, + { prefEnabled, reportBreakageLabel, l10nId }, + { + load, + block, + shim = Ci.nsIWebProgressListener.STATE_REPLACED_TRACKING_CONTENT, + allow = Ci.nsIWebProgressListener.STATE_ALLOWED_TRACKING_CONTENT, + } + ) { + this._id = id; + this.prefEnabled = prefEnabled; + this._reportBreakageLabel = reportBreakageLabel || id; + + this._flags = { load, block, shim, allow }; + + if ( + Services.prefs.getPrefType(this.prefEnabled) == Services.prefs.PREF_BOOL + ) { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_enabled", + this.prefEnabled, + false, + this.updateCategoryItem.bind(this) + ); + } + + l10nId = l10nId || id; + this.strings = {}; + XPCOMUtils.defineLazyGetter(this.strings, "subViewBlocked", () => + gNavigatorBundle.getString(`contentBlocking.${l10nId}View.blocked.label`) + ); + XPCOMUtils.defineLazyGetter(this.strings, "subViewTitleBlocking", () => + gNavigatorBundle.getString(`protections.blocking.${l10nId}.title`) + ); + XPCOMUtils.defineLazyGetter(this.strings, "subViewTitleNotBlocking", () => + gNavigatorBundle.getString(`protections.notBlocking.${l10nId}.title`) + ); + + XPCOMUtils.defineLazyGetter(this, "subView", () => + document.getElementById(`protections-popup-${this._id}View`) + ); + + XPCOMUtils.defineLazyGetter(this, "subViewHeading", () => + document.getElementById(`protections-popup-${this._id}View-heading`) + ); + + XPCOMUtils.defineLazyGetter(this, "subViewList", () => + document.getElementById(`protections-popup-${this._id}View-list`) + ); + + XPCOMUtils.defineLazyGetter(this, "subViewShimAllowHint", () => + document.getElementById( + `protections-popup-${this._id}View-shim-allow-hint` + ) + ); + } + + // Child classes may override these to do init / teardown. We expect them to + // be called when the protections panel is initialized or destroyed. + init() {} + uninit() {} + + // Some child classes may overide this getter. + get enabled() { + return this._enabled; + } + + get reportBreakageLabel() { + return this._reportBreakageLabel; + } + + /** + * Get the category item associated with this protection from the main + * protections panel. + * @returns {xul:toolbarbutton|undefined} - Item or undefined if the panel is + * not yet initialized. + */ + get categoryItem() { + // We don't use defineLazyGetter for the category item, since it may be null + // on first access. + return ( + this._categoryItem || + (this._categoryItem = document.getElementById( + `protections-popup-category-${this._id}` + )) + ); + } + + /** + * Defaults to enabled state. May be overridden by child classes. + * @returns {boolean} - Whether the protection is set to block trackers. + */ + get blockingEnabled() { + return this.enabled; + } + + /** + * Update the category item state in the main view of the protections panel. + * Determines whether the category is set to block trackers. + * @returns {boolean} - true if the state has been updated, false if the + * protections popup has not been initialized yet. + */ + updateCategoryItem() { + // Can't get `this.categoryItem` without the popup. Using the popup instead + // of `this.categoryItem` to guard access, because the category item getter + // can trigger bug 1543537. If there's no popup, we'll be called again the + // first time the popup shows. + if (!gProtectionsHandler._protectionsPopup) { + return false; + } + this.categoryItem.classList.toggle("blocked", this.enabled); + this.categoryItem.classList.toggle("subviewbutton-nav", this.enabled); + return true; + } + + /** + * Update the category sub view that is shown when users click on the category + * button. + */ + async updateSubView() { + let { items, anyShimAllowed } = await this._generateSubViewListItems(); + this.subViewShimAllowHint.hidden = !anyShimAllowed; + + this.subViewList.textContent = ""; + this.subViewList.append(items); + this.subView.setAttribute( + "title", + this.blockingEnabled && !gProtectionsHandler.hasException + ? this.strings.subViewTitleBlocking + : this.strings.subViewTitleNotBlocking + ); + } + + /** + * Create a list of items, each representing a tracker. + * @returns {Object} result - An object containing the results. + * @returns {HTMLDivElement[]} result.items - Generated tracker items. May be + * empty. + * @returns {boolean} result.anyShimAllowed - Flag indicating if any of the + * items have been unblocked by a shim script. + */ + async _generateSubViewListItems() { + let contentBlockingLog = gBrowser.selectedBrowser.getContentBlockingLog(); + contentBlockingLog = JSON.parse(contentBlockingLog); + let anyShimAllowed = false; + + let fragment = document.createDocumentFragment(); + for (let [origin, actions] of Object.entries(contentBlockingLog)) { + let { item, shimAllowed } = await this._createListItem(origin, actions); + if (!item) { + continue; + } + anyShimAllowed = anyShimAllowed || shimAllowed; + fragment.appendChild(item); + } + + return { + items: fragment, + anyShimAllowed, + }; + } + + /** + * Create a DOM item representing a tracker. + * @param {string} origin - Origin of the tracker. + * @param {Array} actions - Array of actions from the content blocking log + * associated with the tracking origin. + * @returns {Object} result - An object containing the results. + * @returns {HTMLDListElement} [options.item] - Generated item or null if we + * don't have an item for this origin based on the actions log. + * @returns {boolean} options.shimAllowed - Flag indicating whether the + * tracking origin was allowed by a shim script. + */ + _createListItem(origin, actions) { + let isAllowed = actions.some( + ([state]) => this.isAllowing(state) && !this.isShimming(state) + ); + let isDetected = + isAllowed || actions.some(([state]) => this.isBlocking(state)); + + if (!isDetected) { + return {}; + } + + // Create an item to hold the origin label and shim allow indicator. Using + // an html element here, so we can use CSS flex, which handles the label + // overflow in combination with the icon correctly. + let listItem = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "div" + ); + listItem.className = "protections-popup-list-item"; + listItem.classList.toggle("allowed", isAllowed); + + let label = document.createXULElement("label"); + // Repeat the host in the tooltip in case it's too long + // and overflows in our panel. + label.tooltipText = origin; + label.value = origin; + label.className = "protections-popup-list-host-label"; + label.setAttribute("crop", "end"); + listItem.append(label); + + // Determine whether we should show a shim-allow indicator for this item. + let shimAllowed = actions.some(([flag]) => flag == this._flags.allow); + if (shimAllowed) { + listItem.append(this._getShimAllowIndicator()); + } + + return { item: listItem, shimAllowed }; + } + + /** + * Create an indicator icon for marking origins that have been allowed by a + * shim script. + * @returns {HTMLImageElement} - Created element. + */ + _getShimAllowIndicator() { + let allowIndicator = document.createXULElement("image"); + document.l10n.setAttributes( + allowIndicator, + "protections-panel-shim-allowed-indicator" + ); + allowIndicator.tooltipText = this.strings.subViewShimAllowedTooltip; + allowIndicator.classList.add( + "protections-popup-list-host-shim-allow-indicator" + ); + return allowIndicator; + } + + /** + * @param {Number} state - Content blocking event flags. + * @returns {boolean} - Whether the protection has blocked a tracker. + */ + isBlocking(state) { + return (state & this._flags.block) != 0; + } + + /** + * @param {Number} state - Content blocking event flags. + * @returns {boolean} - Whether the protection has allowed a tracker. + */ + isAllowing(state) { + return (state & this._flags.load) != 0; + } + + /** + * @param {Number} state - Content blocking event flags. + * @returns {boolean} - Whether the protection has detected (blocked or + * allowed) a tracker. + */ + isDetected(state) { + return this.isBlocking(state) || this.isAllowing(state); + } + + /** + * @param {Number} state - Content blocking event flags. + * @returns {boolean} - Whether the protections has allowed a tracker that + * would have normally been blocked. + */ + isShimming(state) { + return (state & this._flags.shim) != 0 && this.isAllowing(state); + } +} + +let Fingerprinting = new ProtectionCategory( + "fingerprinters", + { + prefEnabled: "privacy.trackingprotection.fingerprinting.enabled", + reportBreakageLabel: "fingerprinting", + }, + { + load: Ci.nsIWebProgressListener.STATE_LOADED_FINGERPRINTING_CONTENT, + block: Ci.nsIWebProgressListener.STATE_BLOCKED_FINGERPRINTING_CONTENT, + shim: Ci.nsIWebProgressListener.STATE_REPLACED_FINGERPRINTING_CONTENT, + allow: Ci.nsIWebProgressListener.STATE_ALLOWED_FINGERPRINTING_CONTENT, + } +); + +let Cryptomining = new ProtectionCategory( + "cryptominers", + { + prefEnabled: "privacy.trackingprotection.cryptomining.enabled", + reportBreakageLabel: "cryptomining", + }, + { + load: Ci.nsIWebProgressListener.STATE_LOADED_CRYPTOMINING_CONTENT, + block: Ci.nsIWebProgressListener.STATE_BLOCKED_CRYPTOMINING_CONTENT, + } +); + +let TrackingProtection = + new (class TrackingProtection extends ProtectionCategory { + constructor() { + super( + "trackers", + { + l10nId: "trackingContent", + prefEnabled: "privacy.trackingprotection.enabled", + reportBreakageLabel: "trackingprotection", + }, + { + load: null, + block: + Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT | + Ci.nsIWebProgressListener.STATE_BLOCKED_EMAILTRACKING_CONTENT, + } + ); + + // Blocked label has custom key, overwrite the getter. + XPCOMUtils.defineLazyGetter(this.strings, "subViewBlocked", () => + gNavigatorBundle.getString("contentBlocking.trackersView.blocked.label") + ); + + this.prefEnabledInPrivateWindows = + "privacy.trackingprotection.pbmode.enabled"; + this.prefTrackingTable = "urlclassifier.trackingTable"; + this.prefTrackingAnnotationTable = + "urlclassifier.trackingAnnotationTable"; + this.prefAnnotationsLevel2Enabled = + "privacy.annotate_channels.strict_list.enabled"; + this.prefEmailTrackingProtectionEnabled = + "privacy.trackingprotection.emailtracking.enabled"; + this.prefEmailTrackingProtectionEnabledInPrivateWindows = + "privacy.trackingprotection.emailtracking.pbmode.enabled"; + + this.enabledGlobally = false; + this.emailTrackingProtectionEnabledGlobally = false; + + this.enabledInPrivateWindows = false; + this.emailTrackingProtectionEnabledInPrivateWindows = false; + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "trackingTable", + this.prefTrackingTable, + "" + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "trackingAnnotationTable", + this.prefTrackingAnnotationTable, + "" + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "annotationsLevel2Enabled", + this.prefAnnotationsLevel2Enabled, + false + ); + } + + init() { + this.updateEnabled(); + + Services.prefs.addObserver(this.prefEnabled, this); + Services.prefs.addObserver(this.prefEnabledInPrivateWindows, this); + Services.prefs.addObserver(this.prefEmailTrackingProtectionEnabled, this); + Services.prefs.addObserver( + this.prefEmailTrackingProtectionEnabledInPrivateWindows, + this + ); + } + + uninit() { + Services.prefs.removeObserver(this.prefEnabled, this); + Services.prefs.removeObserver(this.prefEnabledInPrivateWindows, this); + Services.prefs.removeObserver( + this.prefEmailTrackingProtectionEnabled, + this + ); + Services.prefs.removeObserver( + this.prefEmailTrackingProtectionEnabledInPrivateWindows, + this + ); + } + + observe() { + this.updateEnabled(); + this.updateCategoryItem(); + } + + get trackingProtectionLevel2Enabled() { + const CONTENT_TABLE = "content-track-digest256"; + return this.trackingTable.includes(CONTENT_TABLE); + } + + get enabled() { + return ( + this.enabledGlobally || + this.emailTrackingProtectionEnabledGlobally || + (PrivateBrowsingUtils.isWindowPrivate(window) && + (this.enabledInPrivateWindows || + this.emailTrackingProtectionEnabledInPrivateWindows)) + ); + } + + updateEnabled() { + this.enabledGlobally = Services.prefs.getBoolPref(this.prefEnabled); + this.enabledInPrivateWindows = Services.prefs.getBoolPref( + this.prefEnabledInPrivateWindows + ); + this.emailTrackingProtectionEnabledGlobally = Services.prefs.getBoolPref( + this.prefEmailTrackingProtectionEnabled + ); + this.emailTrackingProtectionEnabledInPrivateWindows = + Services.prefs.getBoolPref( + this.prefEmailTrackingProtectionEnabledInPrivateWindows + ); + } + + isAllowingLevel1(state) { + return ( + (state & + Ci.nsIWebProgressListener.STATE_LOADED_LEVEL_1_TRACKING_CONTENT) != + 0 + ); + } + + isAllowingLevel2(state) { + return ( + (state & + Ci.nsIWebProgressListener.STATE_LOADED_LEVEL_2_TRACKING_CONTENT) != + 0 + ); + } + + isAllowing(state) { + return this.isAllowingLevel1(state) || this.isAllowingLevel2(state); + } + + async updateSubView() { + let previousURI = gBrowser.currentURI.spec; + let previousWindow = gBrowser.selectedBrowser.innerWindowID; + + let { items, anyShimAllowed } = await this._generateSubViewListItems(); + + // If we don't have trackers we would usually not show the menu item + // allowing the user to show the sub-panel. However, in the edge case + // that we annotated trackers on the page using the strict list but did + // not detect trackers on the page using the basic list, we currently + // still show the panel. To reduce the confusion, tell the user that we have + // not detected any tracker. + if (!items.childNodes.length) { + let emptyImage = document.createXULElement("image"); + emptyImage.classList.add("protections-popup-trackersView-empty-image"); + emptyImage.classList.add("trackers-icon"); + + let emptyLabel = document.createXULElement("label"); + emptyLabel.classList.add("protections-popup-empty-label"); + emptyLabel.textContent = gNavigatorBundle.getString( + "contentBlocking.trackersView.empty.label" + ); + + items.appendChild(emptyImage); + items.appendChild(emptyLabel); + + this.subViewList.classList.add("empty"); + } else { + this.subViewList.classList.remove("empty"); + } + + // This might have taken a while. Only update the list if we're still on the same page. + if ( + previousURI == gBrowser.currentURI.spec && + previousWindow == gBrowser.selectedBrowser.innerWindowID + ) { + this.subViewShimAllowHint.hidden = !anyShimAllowed; + + this.subViewList.textContent = ""; + this.subViewList.append(items); + this.subView.setAttribute( + "title", + this.enabled && !gProtectionsHandler.hasException + ? this.strings.subViewTitleBlocking + : this.strings.subViewTitleNotBlocking + ); + } + } + + async _createListItem(origin, actions) { + // Figure out if this list entry was actually detected by TP or something else. + let isAllowed = actions.some( + ([state]) => this.isAllowing(state) && !this.isShimming(state) + ); + let isDetected = + isAllowed || actions.some(([state]) => this.isBlocking(state)); + + if (!isDetected) { + return {}; + } + + // Because we might use different lists for annotation vs. blocking, we + // need to make sure that this is a tracker that we would actually have blocked + // before showing it to the user. + if ( + this.annotationsLevel2Enabled && + !this.trackingProtectionLevel2Enabled && + actions.some( + ([state]) => + (state & + Ci.nsIWebProgressListener + .STATE_LOADED_LEVEL_2_TRACKING_CONTENT) != + 0 + ) + ) { + return {}; + } + + let listItem = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "div" + ); + listItem.className = "protections-popup-list-item"; + listItem.classList.toggle("allowed", isAllowed); + + let label = document.createXULElement("label"); + // Repeat the host in the tooltip in case it's too long + // and overflows in our panel. + label.tooltipText = origin; + label.value = origin; + label.className = "protections-popup-list-host-label"; + label.setAttribute("crop", "end"); + listItem.append(label); + + let shimAllowed = actions.some(([flag]) => flag == this._flags.allow); + if (shimAllowed) { + listItem.append(this._getShimAllowIndicator()); + } + + return { item: listItem, shimAllowed }; + } + })(); + +let ThirdPartyCookies = + new (class ThirdPartyCookies extends ProtectionCategory { + constructor() { + super( + "cookies", + { + // This would normally expect a boolean pref. However, this category + // overwrites the enabled getter for custom handling of cookie behavior + // states. + prefEnabled: "network.cookie.cookieBehavior", + }, + { + // ThirdPartyCookies implements custom flag processing. + allow: null, + shim: null, + load: null, + block: null, + } + ); + + XPCOMUtils.defineLazyGetter(this, "categoryLabel", () => + document.getElementById("protections-popup-cookies-category-label") + ); + + // Not blocking title has custom key, overwrite the getter. + XPCOMUtils.defineLazyGetter(this.strings, "subViewTitleNotBlocking", () => + gNavigatorBundle.getString( + "protections.notBlocking.crossSiteTrackingCookies.title" + ) + ); + + // Cookie permission state label. + XPCOMUtils.defineLazyGetter(this.strings, "subViewAllowed", () => + gNavigatorBundle.getString("contentBlocking.cookiesView.allowed.label") + ); + + this.prefEnabledValues = [ + // These values match the ones exposed under the Content Blocking section + // of the Preferences UI. + Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN, // Block all third-party cookies + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER, // Block third-party cookies from trackers + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, // Block trackers and patition third-party trackers + Ci.nsICookieService.BEHAVIOR_REJECT, // Block all cookies + ]; + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "behaviorPref", + this.prefEnabled, + Ci.nsICookieService.BEHAVIOR_ACCEPT, + this.updateCategoryItem.bind(this) + ); + } + + get reportBreakageLabel() { + switch (this.behaviorPref) { + case Ci.nsICookieService.BEHAVIOR_ACCEPT: + return "nocookiesblocked"; + case Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN: + return "allthirdpartycookiesblocked"; + case Ci.nsICookieService.BEHAVIOR_REJECT: + return "allcookiesblocked"; + case Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN: + return "cookiesfromunvisitedsitesblocked"; + default: + console.error( + `Error: Unknown cookieBehavior pref observed: ${this.behaviorPref}` + ); + // fall through + case Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER: + return "cookierestrictions"; + case Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN: + return "cookierestrictionsforeignpartitioned"; + } + } + + isBlocking(state) { + return ( + (state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_TRACKER) != + 0 || + (state & + Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_SOCIALTRACKER) != + 0 || + (state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_ALL) != 0 || + (state & + Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_BY_PERMISSION) != + 0 || + (state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_FOREIGN) != 0 + ); + } + + isDetected(state) { + if (this.isBlocking(state)) { + return true; + } + + if ( + [ + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER, + Ci.nsICookieService.BEHAVIOR_ACCEPT, + ].includes(this.behaviorPref) + ) { + return ( + (state & Ci.nsIWebProgressListener.STATE_COOKIES_LOADED_TRACKER) != + 0 || + (SocialTracking.enabled && + (state & + Ci.nsIWebProgressListener.STATE_COOKIES_LOADED_SOCIALTRACKER) != + 0) + ); + } + + // We don't have specific flags for the other cookie behaviors so just + // fall back to STATE_COOKIES_LOADED. + return (state & Ci.nsIWebProgressListener.STATE_COOKIES_LOADED) != 0; + } + + updateCategoryItem() { + if (!super.updateCategoryItem()) { + return; + } + let label; + + if (!this.enabled) { + label = "contentBlocking.cookies.blockingTrackers3.label"; + } else { + switch (this.behaviorPref) { + case Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN: + label = "contentBlocking.cookies.blocking3rdParty2.label"; + break; + case Ci.nsICookieService.BEHAVIOR_REJECT: + label = "contentBlocking.cookies.blockingAll2.label"; + break; + case Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN: + label = "contentBlocking.cookies.blockingUnvisited2.label"; + break; + case Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER: + case Ci.nsICookieService + .BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN: + label = "contentBlocking.cookies.blockingTrackers3.label"; + break; + default: + console.error( + `Error: Unknown cookieBehavior pref observed: ${this.behaviorPref}` + ); + break; + } + } + this.categoryLabel.textContent = label + ? gNavigatorBundle.getString(label) + : ""; + } + + get enabled() { + return this.prefEnabledValues.includes(this.behaviorPref); + } + + updateSubView() { + let contentBlockingLog = gBrowser.selectedBrowser.getContentBlockingLog(); + contentBlockingLog = JSON.parse(contentBlockingLog); + + let categories = this._processContentBlockingLog(contentBlockingLog); + + this.subViewList.textContent = ""; + + let categoryNames = ["trackers"]; + switch (this.behaviorPref) { + case Ci.nsICookieService.BEHAVIOR_REJECT: + categoryNames.push("firstParty"); + // eslint-disable-next-line no-fallthrough + case Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN: + categoryNames.push("thirdParty"); + } + + for (let category of categoryNames) { + let itemsToShow = categories[category]; + + if (!itemsToShow.length) { + continue; + } + + let box = document.createXULElement("vbox"); + box.className = "protections-popup-cookiesView-list-section"; + let label = document.createXULElement("label"); + label.className = "protections-popup-cookiesView-list-header"; + label.textContent = gNavigatorBundle.getString( + `contentBlocking.cookiesView.${ + category == "trackers" ? "trackers2" : category + }.label` + ); + box.appendChild(label); + + for (let info of itemsToShow) { + box.appendChild(this._createListItem(info)); + } + + this.subViewList.appendChild(box); + } + + this.subViewHeading.hidden = false; + if (!this.enabled) { + this.subView.setAttribute( + "title", + this.strings.subViewTitleNotBlocking + ); + return; + } + + let title; + let siteException = gProtectionsHandler.hasException; + let titleStringPrefix = `protections.${ + siteException ? "notBlocking" : "blocking" + }.cookies.`; + switch (this.behaviorPref) { + case Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN: + title = titleStringPrefix + "3rdParty.title"; + this.subViewHeading.hidden = true; + if (this.subViewHeading.nextSibling.nodeName == "toolbarseparator") { + this.subViewHeading.nextSibling.hidden = true; + } + break; + case Ci.nsICookieService.BEHAVIOR_REJECT: + title = titleStringPrefix + "all.title"; + this.subViewHeading.hidden = true; + if (this.subViewHeading.nextSibling.nodeName == "toolbarseparator") { + this.subViewHeading.nextSibling.hidden = true; + } + break; + case Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN: + title = "protections.blocking.cookies.unvisited.title"; + this.subViewHeading.hidden = true; + if (this.subViewHeading.nextSibling.nodeName == "toolbarseparator") { + this.subViewHeading.nextSibling.hidden = true; + } + break; + case Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER: + case Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN: + title = siteException + ? "protections.notBlocking.crossSiteTrackingCookies.title" + : "protections.blocking.cookies.trackers.title"; + break; + default: + console.error( + `Error: Unknown cookieBehavior pref when updating subview: ${this.behaviorPref}` + ); + break; + } + + this.subView.setAttribute("title", gNavigatorBundle.getString(title)); + } + + _getExceptionState(origin) { + let thirdPartyStorage = Services.perms.testPermissionFromPrincipal( + gBrowser.contentPrincipal, + "3rdPartyStorage^" + origin + ); + + if (thirdPartyStorage != Services.perms.UNKNOWN_ACTION) { + return thirdPartyStorage; + } + + let principal = + Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin); + // Cookie exceptions get "inherited" from parent- to sub-domain, so we need to + // make sure to include parent domains in the permission check for "cookie". + return Services.perms.testPermissionFromPrincipal(principal, "cookie"); + } + + _clearException(origin) { + for (let perm of Services.perms.getAllForPrincipal( + gBrowser.contentPrincipal + )) { + if (perm.type == "3rdPartyStorage^" + origin) { + Services.perms.removePermission(perm); + } + } + + // OAs don't matter here, so we can just use the hostname. + let host = Services.io.newURI(origin).host; + + // Cookie exceptions get "inherited" from parent- to sub-domain, so we need to + // clear any cookie permissions from parent domains as well. + for (let perm of Services.perms.all) { + if ( + perm.type == "cookie" && + Services.eTLD.hasRootDomain(host, perm.principal.host) + ) { + Services.perms.removePermission(perm); + } + } + } + + // Transforms and filters cookie entries in the content blocking log + // so that we can categorize and display them in the UI. + _processContentBlockingLog(log) { + let newLog = { + firstParty: [], + trackers: [], + thirdParty: [], + }; + + let firstPartyDomain = null; + try { + firstPartyDomain = Services.eTLD.getBaseDomain(gBrowser.currentURI); + } catch (e) { + // There are nasty edge cases here where someone is trying to set a cookie + // on a public suffix or an IP address. Just categorize those as third party... + if ( + e.result != Cr.NS_ERROR_HOST_IS_IP_ADDRESS && + e.result != Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS + ) { + throw e; + } + } + + for (let [origin, actions] of Object.entries(log)) { + if (!origin.startsWith("http")) { + continue; + } + + let info = { + origin, + isAllowed: true, + exceptionState: this._getExceptionState(origin), + }; + let hasCookie = false; + let isTracker = false; + + // Extract information from the states entries in the content blocking log. + // Each state will contain a single state flag from nsIWebProgressListener. + // Note that we are using the same helper functions that are applied to the + // bit map passed to onSecurityChange (which contains multiple states), thus + // not checking exact equality, just presence of bits. + for (let [state, blocked] of actions) { + if (this.isDetected(state)) { + hasCookie = true; + } + if (TrackingProtection.isAllowing(state)) { + isTracker = true; + } + // blocked tells us whether the resource was actually blocked + // (which it may not be in case of an exception). + if (this.isBlocking(state)) { + info.isAllowed = !blocked; + } + } + + if (!hasCookie) { + continue; + } + + let isFirstParty = false; + try { + let uri = Services.io.newURI(origin); + isFirstParty = Services.eTLD.getBaseDomain(uri) == firstPartyDomain; + } catch (e) { + if ( + e.result != Cr.NS_ERROR_HOST_IS_IP_ADDRESS && + e.result != Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS + ) { + throw e; + } + } + + if (isFirstParty) { + newLog.firstParty.push(info); + } else if (isTracker) { + newLog.trackers.push(info); + } else { + newLog.thirdParty.push(info); + } + } + + return newLog; + } + + _createListItem({ origin, isAllowed, exceptionState }) { + let listItem = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "div" + ); + listItem.className = "protections-popup-list-item"; + // Repeat the origin in the tooltip in case it's too long + // and overflows in our panel. + listItem.tooltipText = origin; + + let label = document.createXULElement("label"); + label.value = origin; + label.className = "protections-popup-list-host-label"; + label.setAttribute("crop", "end"); + listItem.append(label); + + if ( + (isAllowed && exceptionState == Services.perms.ALLOW_ACTION) || + (!isAllowed && exceptionState == Services.perms.DENY_ACTION) + ) { + listItem.classList.add("protections-popup-list-item-with-state"); + + let stateLabel = document.createXULElement("label"); + stateLabel.className = "protections-popup-list-state-label"; + if (isAllowed) { + stateLabel.value = this.strings.subViewAllowed; + listItem.classList.toggle("allowed", true); + } else { + stateLabel.value = this.strings.subViewBlocked; + } + + let removeException = document.createXULElement("button"); + removeException.className = "permission-popup-permission-remove-button"; + removeException.tooltipText = gNavigatorBundle.getFormattedString( + "contentBlocking.cookiesView.removeButton.tooltip", + [origin] + ); + removeException.appendChild(stateLabel); + + removeException.addEventListener( + "click", + () => { + this._clearException(origin); + removeException.remove(); + listItem.classList.toggle("allowed", !isAllowed); + }, + { once: true } + ); + listItem.append(removeException); + } + + return listItem; + } + })(); + +let SocialTracking = + new (class SocialTrackingProtection extends ProtectionCategory { + constructor() { + super( + "socialblock", + { + l10nId: "socialMediaTrackers", + prefEnabled: "privacy.socialtracking.block_cookies.enabled", + reportBreakageLabel: "socialtracking", + }, + { + load: Ci.nsIWebProgressListener.STATE_LOADED_SOCIALTRACKING_CONTENT, + block: Ci.nsIWebProgressListener.STATE_BLOCKED_SOCIALTRACKING_CONTENT, + } + ); + + this.prefStpTpEnabled = + "privacy.trackingprotection.socialtracking.enabled"; + this.prefSTPCookieEnabled = this.prefEnabled; + this.prefCookieBehavior = "network.cookie.cookieBehavior"; + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "socialTrackingProtectionEnabled", + this.prefStpTpEnabled, + false, + this.updateCategoryItem.bind(this) + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "rejectTrackingCookies", + this.prefCookieBehavior, + null, + this.updateCategoryItem.bind(this), + val => + [ + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER, + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, + ].includes(val) + ); + } + + get blockingEnabled() { + return ( + (this.socialTrackingProtectionEnabled || this.rejectTrackingCookies) && + this.enabled + ); + } + + isBlockingCookies(state) { + return ( + (state & + Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_SOCIALTRACKER) != + 0 + ); + } + + isBlocking(state) { + return super.isBlocking(state) || this.isBlockingCookies(state); + } + + isAllowing(state) { + if (this.socialTrackingProtectionEnabled) { + return super.isAllowing(state); + } + + return ( + (state & + Ci.nsIWebProgressListener.STATE_COOKIES_LOADED_SOCIALTRACKER) != + 0 + ); + } + + updateCategoryItem() { + // Can't get `this.categoryItem` without the popup. Using the popup instead + // of `this.categoryItem` to guard access, because the category item getter + // can trigger bug 1543537. If there's no popup, we'll be called again the + // first time the popup shows. + if (!gProtectionsHandler._protectionsPopup) { + return; + } + if (this.enabled) { + this.categoryItem.removeAttribute("uidisabled"); + } else { + this.categoryItem.setAttribute("uidisabled", true); + } + this.categoryItem.classList.toggle("blocked", this.blockingEnabled); + } + })(); + +/** + * Singleton to manage the cookie banner feature section in the protections + * panel and the cookie banner handling subview. + */ +let cookieBannerHandling = new (class { + // Check if this is a private window. We don't expect PBM state to change + // during the lifetime of this window. + #isPrivateBrowsing = PrivateBrowsingUtils.isWindowPrivate(window); + + constructor() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_serviceModePref", + "cookiebanners.service.mode", + Ci.nsICookieBannerService.MODE_DISABLED + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_serviceModePrefPrivateBrowsing", + "cookiebanners.service.mode.privateBrowsing", + Ci.nsICookieBannerService.MODE_DISABLED + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_serviceDetectOnly", + "cookiebanners.service.detectOnly", + false + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_uiEnabled", + "cookiebanners.ui.desktop.enabled", + false + ); + XPCOMUtils.defineLazyGetter(this, "_cookieBannerSection", () => + document.getElementById("protections-popup-cookie-banner-section") + ); + XPCOMUtils.defineLazyGetter(this, "_cookieBannerSectionSeparator", () => + document.getElementById( + "protections-popup-cookie-banner-section-separator" + ) + ); + XPCOMUtils.defineLazyGetter(this, "_cookieBannerSwitch", () => + document.getElementById("protections-popup-cookie-banner-switch") + ); + XPCOMUtils.defineLazyGetter(this, "_cookieBannerSubview", () => + document.getElementById("protections-popup-cookieBannerView") + ); + XPCOMUtils.defineLazyGetter(this, "_cookieBannerEnableSite", () => + document.getElementById("cookieBannerView-enable-site") + ); + XPCOMUtils.defineLazyGetter(this, "_cookieBannerDisableSite", () => + document.getElementById("cookieBannerView-disable-site") + ); + } + + /** + * Tests if the current site has a user-created exception from the default + * cookie banner handling mode. Currently that means the feature is disabled + * for the current site. + * + * Note: bug 1790688 will move this mode handling logic into the + * nsCookieBannerService. + * + * @returns {boolean} - true if the user has manually created an exception. + */ + get #hasException() { + // If the CBH feature is preffed off, we can't have an exception. + if (!Services.cookieBanners.isEnabled) { + return false; + } + + // URLs containing IP addresses are not supported by the CBH service, and + // will throw. In this case, users can't create an exception, so initialize + // `pref` to the default value returned by `getDomainPref`. + let pref = Ci.nsICookieBannerService.MODE_UNSET; + try { + pref = Services.cookieBanners.getDomainPref( + gBrowser.currentURI, + this.#isPrivateBrowsing + ); + } catch (ex) { + console.error( + "Cookie Banner Handling error checking for per-site exceptions: ", + ex + ); + } + return pref == Ci.nsICookieBannerService.MODE_DISABLED; + } + + /** + * Tests if the cookie banner handling code supports the current site. + * + * See nsICookieBannerService.hasRuleForBrowsingContextTree for details. + * + * @returns {boolean} - true if the base domain is in the list of rules. + */ + get isSiteSupported() { + return ( + Services.cookieBanners.isEnabled && + Services.cookieBanners.hasRuleForBrowsingContextTree( + gBrowser.selectedBrowser.browsingContext + ) + ); + } + + /* + * @returns {string} - Base domain (eTLD + 1) used for clearing site data. + */ + get #currentBaseDomain() { + return gBrowser.contentPrincipal.baseDomain; + } + + /** + * Helper method used by both updateSection and updateSubView to map internal + * state to UI attribute state. We have to separately set the subview's state + * because the subview is not a descendant of the menu item in the DOM, and + * we rely on CSS to toggle UI visibility based on attribute state. + * + * @returns A string value to be set as a UI attribute value. + */ + get #uiState() { + if (this.#hasException) { + return "site-disabled"; + } else if (this.isSiteSupported) { + return "detected"; + } + return "undetected"; + } + + updateSection() { + let showSection = this.#shouldShowSection(); + let state = this.#uiState; + + for (let el of [ + this._cookieBannerSection, + this._cookieBannerSectionSeparator, + ]) { + el.hidden = !showSection; + } + + this._cookieBannerSection.dataset.state = state; + + // On unsupported sites, disable button styling and click behavior. + // Note: to be replaced with a "please support site" subview in bug 1801971. + if (state == "undetected") { + this._cookieBannerSection.setAttribute("disabled", true); + this._cookieBannerSwitch.classList.remove("subviewbutton-nav"); + this._cookieBannerSwitch.setAttribute("disabled", true); + } else { + this._cookieBannerSection.removeAttribute("disabled"); + this._cookieBannerSwitch.classList.add("subviewbutton-nav"); + this._cookieBannerSwitch.removeAttribute("disabled"); + } + } + + #shouldShowSection() { + // Don't show UI if globally disabled by pref, or if the cookie service + // is in detect-only mode. + if (!this._uiEnabled || this._serviceDetectOnly) { + return false; + } + + // Show the section if the feature is not in disabled mode, being sure to + // check the different prefs for regular and private windows. + if (this.#isPrivateBrowsing) { + return ( + this._serviceModePrefPrivateBrowsing != + Ci.nsICookieBannerService.MODE_DISABLED + ); + } + return this._serviceModePref != Ci.nsICookieBannerService.MODE_DISABLED; + } + + /* + * Updates the cookie banner handling subview just before it's shown. + */ + updateSubView() { + this._cookieBannerSubview.dataset.state = this.#uiState; + + let baseDomain = JSON.stringify({ host: this.#currentBaseDomain }); + this._cookieBannerEnableSite.setAttribute("data-l10n-args", baseDomain); + this._cookieBannerDisableSite.setAttribute("data-l10n-args", baseDomain); + } + + async #disableCookieBannerHandling() { + // We can't clear data during a private browsing session until bug 1818783 + // is fixed. In the meantime, don't allow the cookie banner controls in a + // private window to clear data for regular browsing mode. + if (!this.#isPrivateBrowsing) { + await SiteDataManager.remove(this.#currentBaseDomain); + } + Services.cookieBanners.setDomainPref( + gBrowser.currentURI, + Ci.nsICookieBannerService.MODE_DISABLED, + this.#isPrivateBrowsing + ); + } + + #enableCookieBannerHandling() { + Services.cookieBanners.removeDomainPref( + gBrowser.currentURI, + this.#isPrivateBrowsing + ); + } + + async onCookieBannerToggleCommand() { + let hasException = + this._cookieBannerSection.toggleAttribute("hasException"); + if (hasException) { + await this.#disableCookieBannerHandling(); + gProtectionsHandler.recordClick("cookieb_toggle_off"); + } else { + this.#enableCookieBannerHandling(); + gProtectionsHandler.recordClick("cookieb_toggle_on"); + } + gProtectionsHandler._hidePopup(); + gBrowser.reloadTab(gBrowser.selectedTab); + } +})(); + +/** + * Utility object to handle manipulations of the protections indicators in the UI + */ +var gProtectionsHandler = { + PREF_REPORT_BREAKAGE_URL: "browser.contentblocking.reportBreakage.url", + PREF_CB_CATEGORY: "browser.contentblocking.category", + + _protectionsPopup: null, + _initializePopup() { + if (!this._protectionsPopup) { + let wrapper = document.getElementById("template-protections-popup"); + this._protectionsPopup = wrapper.content.firstElementChild; + wrapper.replaceWith(wrapper.content); + + this.maybeSetMilestoneCounterText(); + + for (let blocker of Object.values(this.blockers)) { + blocker.updateCategoryItem(); + } + + let baseURL = Services.urlFormatter.formatURLPref("app.support.baseURL"); + document.getElementById( + "protections-popup-sendReportView-learn-more" + ).href = baseURL + "blocking-breakage"; + + let shimAllowLearnMoreURL = + baseURL + "smartblock-enhanced-tracking-protection"; + + document + .querySelectorAll(".protections-popup-shim-allow-learn-more") + .forEach(label => { + label.href = shimAllowLearnMoreURL; + }); + } + }, + + _hidePopup() { + if (this._protectionsPopup) { + PanelMultiView.hidePopup(this._protectionsPopup); + } + }, + + // smart getters + get iconBox() { + delete this.iconBox; + return (this.iconBox = document.getElementById( + "tracking-protection-icon-box" + )); + }, + get _protectionsPopupMultiView() { + delete this._protectionsPopupMultiView; + return (this._protectionsPopupMultiView = document.getElementById( + "protections-popup-multiView" + )); + }, + get _protectionsPopupMainView() { + delete this._protectionsPopupMainView; + return (this._protectionsPopupMainView = document.getElementById( + "protections-popup-mainView" + )); + }, + get _protectionsPopupMainViewHeaderLabel() { + delete this._protectionsPopupMainViewHeaderLabel; + return (this._protectionsPopupMainViewHeaderLabel = document.getElementById( + "protections-popup-mainView-panel-header-span" + )); + }, + get _protectionsPopupTPSwitchBreakageLink() { + delete this._protectionsPopupTPSwitchBreakageLink; + return (this._protectionsPopupTPSwitchBreakageLink = + document.getElementById("protections-popup-tp-switch-breakage-link")); + }, + get _protectionsPopupTPSwitchBreakageFixedLink() { + delete this._protectionsPopupTPSwitchBreakageFixedLink; + return (this._protectionsPopupTPSwitchBreakageFixedLink = + document.getElementById( + "protections-popup-tp-switch-breakage-fixed-link" + )); + }, + get _protectionsPopupTPSwitch() { + delete this._protectionsPopupTPSwitch; + return (this._protectionsPopupTPSwitch = document.getElementById( + "protections-popup-tp-switch" + )); + }, + get _protectionsPopupBlockingHeader() { + delete this._protectionsPopupBlockingHeader; + return (this._protectionsPopupBlockingHeader = document.getElementById( + "protections-popup-blocking-section-header" + )); + }, + get _protectionsPopupNotBlockingHeader() { + delete this._protectionsPopupNotBlockingHeader; + return (this._protectionsPopupNotBlockingHeader = document.getElementById( + "protections-popup-not-blocking-section-header" + )); + }, + get _protectionsPopupNotFoundHeader() { + delete this._protectionsPopupNotFoundHeader; + return (this._protectionsPopupNotFoundHeader = document.getElementById( + "protections-popup-not-found-section-header" + )); + }, + get _protectionsPopupSettingsButton() { + delete this._protectionsPopupSettingsButton; + return (this._protectionsPopupSettingsButton = document.getElementById( + "protections-popup-settings-button" + )); + }, + get _protectionsPopupFooter() { + delete this._protectionsPopupFooter; + return (this._protectionsPopupFooter = document.getElementById( + "protections-popup-footer" + )); + }, + get _protectionsPopupTrackersCounterBox() { + delete this._protectionsPopupTrackersCounterBox; + return (this._protectionsPopupTrackersCounterBox = document.getElementById( + "protections-popup-trackers-blocked-counter-box" + )); + }, + get _protectionsPopupTrackersCounterDescription() { + delete this._protectionsPopupTrackersCounterDescription; + return (this._protectionsPopupTrackersCounterDescription = + document.getElementById( + "protections-popup-trackers-blocked-counter-description" + )); + }, + get _protectionsPopupFooterProtectionTypeLabel() { + delete this._protectionsPopupFooterProtectionTypeLabel; + return (this._protectionsPopupFooterProtectionTypeLabel = + document.getElementById( + "protections-popup-footer-protection-type-label" + )); + }, + get _protectionsPopupSiteNotWorkingTPSwitch() { + delete this._protectionsPopupSiteNotWorkingTPSwitch; + return (this._protectionsPopupSiteNotWorkingTPSwitch = + document.getElementById("protections-popup-siteNotWorking-tp-switch")); + }, + get _protectionsPopupSiteNotWorkingReportError() { + delete this._protectionsPopupSiteNotWorkingReportError; + return (this._protectionsPopupSiteNotWorkingReportError = + document.getElementById("protections-popup-sendReportView-report-error")); + }, + get _protectionsPopupSendReportURL() { + delete this._protectionsPopupSendReportURL; + return (this._protectionsPopupSendReportURL = document.getElementById( + "protections-popup-sendReportView-collection-url" + )); + }, + get _protectionsPopupSendReportButton() { + delete this._protectionsPopupSendReportButton; + return (this._protectionsPopupSendReportButton = document.getElementById( + "protections-popup-sendReportView-submit" + )); + }, + get _trackingProtectionIconTooltipLabel() { + delete this._trackingProtectionIconTooltipLabel; + return (this._trackingProtectionIconTooltipLabel = document.getElementById( + "tracking-protection-icon-tooltip-label" + )); + }, + get _trackingProtectionIconContainer() { + delete this._trackingProtectionIconContainer; + return (this._trackingProtectionIconContainer = document.getElementById( + "tracking-protection-icon-container" + )); + }, + + get noTrackersDetectedDescription() { + delete this.noTrackersDetectedDescription; + return (this.noTrackersDetectedDescription = document.getElementById( + "protections-popup-no-trackers-found-description" + )); + }, + + get _protectionsPopupMilestonesText() { + delete this._protectionsPopupMilestonesText; + return (this._protectionsPopupMilestonesText = document.getElementById( + "protections-popup-milestones-text" + )); + }, + + get _notBlockingWhyLink() { + delete this._notBlockingWhyLink; + return (this._notBlockingWhyLink = document.getElementById( + "protections-popup-not-blocking-section-why" + )); + }, + + get _siteNotWorkingIssueListFonts() { + delete this._siteNotWorkingIssueListFonts; + return (this._siteNotWorkingIssueListFonts = document.getElementById( + "protections-panel-site-not-working-view-issue-list-fonts" + )); + }, + + strings: { + get activeTooltipText() { + delete this.activeTooltipText; + return (this.activeTooltipText = gNavigatorBundle.getString( + "trackingProtection.icon.activeTooltip2" + )); + }, + + get disabledTooltipText() { + delete this.disabledTooltipText; + return (this.disabledTooltipText = gNavigatorBundle.getString( + "trackingProtection.icon.disabledTooltip2" + )); + }, + + get noTrackerTooltipText() { + delete this.noTrackerTooltipText; + return (this.noTrackerTooltipText = gNavigatorBundle.getFormattedString( + "trackingProtection.icon.noTrackersDetectedTooltip", + [gBrandBundle.GetStringFromName("brandShortName")] + )); + }, + }, + + // A list of blockers that will be displayed in the categories list + // when blockable content is detected. A blocker must be an object + // with at least the following two properties: + // - enabled: Whether the blocker is currently turned on. + // - isDetected(state): Given a content blocking state, whether the blocker has + // either allowed or blocked elements. + // - categoryItem: The DOM item that represents the entry in the category list. + // + // It may also contain an init() and uninit() function, which will be called + // on gProtectionsHandler.init() and gProtectionsHandler.uninit(). + // The buttons in the protections panel will appear in the same order as this array. + blockers: { + SocialTracking, + ThirdPartyCookies, + TrackingProtection, + Fingerprinting, + Cryptomining, + }, + + init() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_fontVisibilityTrackingProtection", + "layout.css.font-visibility.trackingprotection", + 3000 + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_protectionsPopupToastTimeout", + "browser.protections_panel.toast.timeout", + 3000 + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "milestoneListPref", + "browser.contentblocking.cfr-milestone.milestones", + "[]", + () => this.maybeSetMilestoneCounterText(), + val => JSON.parse(val) + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "milestonePref", + "browser.contentblocking.cfr-milestone.milestone-achieved", + 0, + () => this.maybeSetMilestoneCounterText() + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "milestoneTimestampPref", + "browser.contentblocking.cfr-milestone.milestone-shown-time", + "0", + null, + val => parseInt(val) + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "milestonesEnabledPref", + "browser.contentblocking.cfr-milestone.enabled", + false, + () => this.maybeSetMilestoneCounterText() + ); + + for (let blocker of Object.values(this.blockers)) { + if (blocker.init) { + blocker.init(); + } + } + + // Add an observer to observe that the history has been cleared. + Services.obs.addObserver(this, "browser:purge-session-history"); + }, + + uninit() { + for (let blocker of Object.values(this.blockers)) { + if (blocker.uninit) { + blocker.uninit(); + } + } + + Services.obs.removeObserver(this, "browser:purge-session-history"); + }, + + getTrackingProtectionLabel() { + const value = Services.prefs.getStringPref(this.PREF_CB_CATEGORY); + + switch (value) { + case "strict": + return "protections-popup-footer-protection-label-strict"; + case "custom": + return "protections-popup-footer-protection-label-custom"; + case "standard": + /* fall through */ + default: + return "protections-popup-footer-protection-label-standard"; + } + }, + + openPreferences(origin) { + openPreferences("privacy-trackingprotection", { origin }); + }, + + openProtections(relatedToCurrent = false) { + switchToTabHavingURI("about:protections", true, { + replaceQueryString: true, + relatedToCurrent, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + + // Don't show the milestones section anymore. + Services.prefs.clearUserPref( + "browser.contentblocking.cfr-milestone.milestone-shown-time" + ); + }, + + async showTrackersSubview(event) { + await TrackingProtection.updateSubView(); + this._protectionsPopupMultiView.showSubView( + "protections-popup-trackersView" + ); + }, + + async showSocialblockerSubview(event) { + await SocialTracking.updateSubView(); + this._protectionsPopupMultiView.showSubView( + "protections-popup-socialblockView" + ); + }, + + async showCookiesSubview(event) { + await ThirdPartyCookies.updateSubView(); + this._protectionsPopupMultiView.showSubView( + "protections-popup-cookiesView" + ); + }, + + async showFingerprintersSubview(event) { + await Fingerprinting.updateSubView(); + this._protectionsPopupMultiView.showSubView( + "protections-popup-fingerprintersView" + ); + }, + + async showCryptominersSubview(event) { + await Cryptomining.updateSubView(); + this._protectionsPopupMultiView.showSubView( + "protections-popup-cryptominersView" + ); + }, + + async onCookieBannerClick(event) { + if (!cookieBannerHandling.isSiteSupported) { + return; + } + await cookieBannerHandling.updateSubView(); + this._protectionsPopupMultiView.showSubView( + "protections-popup-cookieBannerView" + ); + }, + + recordClick(object, value = null, source = "protectionspopup") { + Services.telemetry.recordEvent( + `security.ui.${source}`, + "click", + object, + value + ); + }, + + shieldHistogramAdd(value) { + if (PrivateBrowsingUtils.isWindowPrivate(window)) { + return; + } + Services.telemetry + .getHistogramById("TRACKING_PROTECTION_SHIELD") + .add(value); + }, + + cryptominersHistogramAdd(value) { + Services.telemetry + .getHistogramById("CRYPTOMINERS_BLOCKED_COUNT") + .add(value); + }, + + fingerprintersHistogramAdd(value) { + Services.telemetry + .getHistogramById("FINGERPRINTERS_BLOCKED_COUNT") + .add(value); + }, + + handleProtectionsButtonEvent(event) { + event.stopPropagation(); + if ( + (event.type == "click" && event.button != 0) || + (event.type == "keypress" && + event.charCode != KeyEvent.DOM_VK_SPACE && + event.keyCode != KeyEvent.DOM_VK_RETURN) + ) { + return; // Left click, space or enter only + } + + this.showProtectionsPopup({ event }); + }, + + onPopupShown(event) { + if (event.target == this._protectionsPopup) { + window.ensureCustomElements("moz-button-group"); + + PopupNotifications.suppressWhileOpen(this._protectionsPopup); + + window.addEventListener("focus", this, true); + + // Insert the info message if needed. This will be shown once and then + // remain collapsed. + ToolbarPanelHub.insertProtectionPanelMessage(event); + + if (!event.target.hasAttribute("toast")) { + Services.telemetry.recordEvent( + "security.ui.protectionspopup", + "open", + "protections_popup" + ); + } + } + }, + + onPopupHidden(event) { + if (event.target == this._protectionsPopup) { + window.removeEventListener("focus", this, true); + } + }, + + onHeaderClicked(event) { + // Display the whole protections panel if the toast has been clicked. + if (this._protectionsPopup.hasAttribute("toast")) { + // Hide the toast first. + PanelMultiView.hidePopup(this._protectionsPopup); + + // Open the full protections panel. + this.showProtectionsPopup({ event }); + } + }, + + async onTrackingProtectionIconHoveredOrFocused() { + // We would try to pre-fetch the data whenever the shield icon is hovered or + // focused. We check focus event here due to the keyboard navigation. + if (this._updatingFooter) { + return; + } + this._updatingFooter = true; + + // Take the popup out of its template. + this._initializePopup(); + + // Get the tracker count and set it to the counter in the footer. + const trackerCount = await TrackingDBService.sumAllEvents(); + this.setTrackersBlockedCounter(trackerCount); + + // Set tracking protection label + const l10nId = this.getTrackingProtectionLabel(); + const elem = this._protectionsPopupFooterProtectionTypeLabel; + document.l10n.setAttributes(elem, l10nId); + + // Try to get the earliest recorded date in case that there was no record + // during the initiation but new records come after that. + await this.maybeUpdateEarliestRecordedDateTooltip(); + + this._updatingFooter = false; + }, + + // This triggers from top level location changes. + onLocationChange() { + if (this._showToastAfterRefresh) { + this._showToastAfterRefresh = false; + + // We only display the toast if we're still on the same page. + if ( + this._previousURI == gBrowser.currentURI.spec && + this._previousOuterWindowID == gBrowser.selectedBrowser.outerWindowID + ) { + this.showProtectionsPopup({ + toast: true, + }); + } + } + + // Reset blocking and exception status so that we can send telemetry + this.hadShieldState = false; + + // Don't deal with about:, file: etc. + if (!ContentBlockingAllowList.canHandle(gBrowser.selectedBrowser)) { + // We hide the icon and thus avoid showing the doorhanger, since + // the information contained there would mostly be broken and/or + // irrelevant anyway. + this._trackingProtectionIconContainer.hidden = true; + return; + } + this._trackingProtectionIconContainer.hidden = false; + + // Check whether the user has added an exception for this site. + this.hasException = ContentBlockingAllowList.includes( + gBrowser.selectedBrowser + ); + + if (this._protectionsPopup) { + this._protectionsPopup.toggleAttribute("hasException", this.hasException); + } + this.iconBox.toggleAttribute("hasException", this.hasException); + + // Add to telemetry per page load as a baseline measurement. + this.fingerprintersHistogramAdd("pageLoad"); + this.cryptominersHistogramAdd("pageLoad"); + this.shieldHistogramAdd(0); + }, + + notifyContentBlockingEvent(event) { + // We don't notify observers until the document stops loading, therefore + // a merged event can be sent, which gives an opportunity to decide the + // priority by the handler. + // Content blocking events coming after stopping will not be merged, and are + // sent directly. + if (!this._isStoppedState || !this.anyDetected) { + return; + } + + let uri = gBrowser.currentURI; + let uriHost = uri.asciiHost ? uri.host : uri.spec; + Services.obs.notifyObservers( + { + wrappedJSObject: { + browser: gBrowser.selectedBrowser, + host: uriHost, + event, + }, + }, + "SiteProtection:ContentBlockingEvent" + ); + }, + + onStateChange(aWebProgress, stateFlags) { + if (!aWebProgress.isTopLevel) { + return; + } + + this._isStoppedState = !!( + stateFlags & Ci.nsIWebProgressListener.STATE_STOP + ); + this.notifyContentBlockingEvent( + gBrowser.selectedBrowser.getContentBlockingEvents() + ); + }, + + /** + * Update the in-panel UI given a blocking event. Called when the popup + * is being shown, or when the popup is open while a new event comes in. + */ + updatePanelForBlockingEvent(event) { + // Update the categories: + for (let blocker of Object.values(this.blockers)) { + if (blocker.categoryItem.hasAttribute("uidisabled")) { + continue; + } + blocker.categoryItem.classList.toggle( + "notFound", + !blocker.isDetected(event) + ); + blocker.categoryItem.classList.toggle( + "subviewbutton-nav", + blocker.isDetected(event) + ); + } + + // And the popup attributes: + this._protectionsPopup.toggleAttribute("detected", this.anyDetected); + this._protectionsPopup.toggleAttribute("blocking", this.anyBlocking); + this._protectionsPopup.toggleAttribute("hasException", this.hasException); + + this.noTrackersDetectedDescription.hidden = this.anyDetected; + + if (this.anyDetected) { + // Reorder categories if any are in use. + this.reorderCategoryItems(); + } + }, + + reportBlockingEventTelemetry(event, isSimulated, previousState) { + if (!isSimulated) { + if (this.hasException && !this.hadShieldState) { + this.hadShieldState = true; + this.shieldHistogramAdd(1); + } else if ( + !this.hasException && + this.anyBlocking && + !this.hadShieldState + ) { + this.hadShieldState = true; + this.shieldHistogramAdd(2); + } + } + + // We report up to one instance of fingerprinting and cryptomining + // blocking and/or allowing per page load. + let fingerprintingBlocking = + Fingerprinting.isBlocking(event) && + !Fingerprinting.isBlocking(previousState); + let fingerprintingAllowing = + Fingerprinting.isAllowing(event) && + !Fingerprinting.isAllowing(previousState); + let cryptominingBlocking = + Cryptomining.isBlocking(event) && !Cryptomining.isBlocking(previousState); + let cryptominingAllowing = + Cryptomining.isAllowing(event) && !Cryptomining.isAllowing(previousState); + + if (fingerprintingBlocking) { + this.fingerprintersHistogramAdd("blocked"); + } else if (fingerprintingAllowing) { + this.fingerprintersHistogramAdd("allowed"); + } + + if (cryptominingBlocking) { + this.cryptominersHistogramAdd("blocked"); + } else if (cryptominingAllowing) { + this.cryptominersHistogramAdd("allowed"); + } + }, + + onContentBlockingEvent(event, webProgress, isSimulated, previousState) { + // Don't deal with about:, file: etc. + if (!ContentBlockingAllowList.canHandle(gBrowser.selectedBrowser)) { + this.iconBox.removeAttribute("active"); + this.iconBox.removeAttribute("hasException"); + return; + } + + // First update all our internal state based on the allowlist and the + // different blockers: + this.anyDetected = false; + this.anyBlocking = false; + this._lastEvent = event; + + // Check whether the user has added an exception for this site. + this.hasException = ContentBlockingAllowList.includes( + gBrowser.selectedBrowser + ); + + // Update blocker state and find if they detected or blocked anything. + for (let blocker of Object.values(this.blockers)) { + if (blocker.categoryItem?.hasAttribute("uidisabled")) { + continue; + } + // Store data on whether the blocker is activated for reporting it + // using the "report breakage" dialog. Under normal circumstances this + // dialog should only be able to open in the currently selected tab + // and onSecurityChange runs on tab switch, so we can avoid associating + // the data with the document directly. + blocker.activated = blocker.isBlocking(event); + this.anyDetected = this.anyDetected || blocker.isDetected(event); + this.anyBlocking = this.anyBlocking || blocker.activated; + } + + this._categoryItemOrderInvalidated = true; + + // Now, update the icon UI: + + // We consider the shield state "active" when some kind of blocking activity + // occurs on the page. Note that merely allowing the loading of content that + // we could have blocked does not trigger the appearance of the shield. + // This state will be overriden later if there's an exception set for this site. + this.iconBox.toggleAttribute("active", this.anyBlocking); + this.iconBox.toggleAttribute("hasException", this.hasException); + + // Update the icon's tooltip: + if (this.hasException) { + this.showDisabledTooltipForTPIcon(); + } else if (this.anyBlocking) { + this.showActiveTooltipForTPIcon(); + } else { + this.showNoTrackerTooltipForTPIcon(); + } + + // Update the panel if it's open. + let isPanelOpen = ["showing", "open"].includes( + this._protectionsPopup?.state + ); + if (isPanelOpen) { + this.updatePanelForBlockingEvent(event); + } + + // Notify other consumers, like CFR. + // Don't send a content blocking event to CFR for + // tab switches since this will already be done via + // onStateChange. + if (!isSimulated) { + this.notifyContentBlockingEvent(event); + } + + // Finally, report telemetry. + this.reportBlockingEventTelemetry(event, isSimulated, previousState); + }, + + // We handle focus here when the panel is shown. + handleEvent(event) { + let elem = document.activeElement; + let position = elem.compareDocumentPosition(this._protectionsPopup); + + if ( + !( + position & + (Node.DOCUMENT_POSITION_CONTAINS | Node.DOCUMENT_POSITION_CONTAINED_BY) + ) && + !this._protectionsPopup.hasAttribute("noautohide") + ) { + // Hide the panel when focusing an element that is + // neither an ancestor nor descendant unless the panel has + // @noautohide (e.g. for a tour). + PanelMultiView.hidePopup(this._protectionsPopup); + } + }, + + observe(subject, topic, data) { + switch (topic) { + case "browser:purge-session-history": + // We need to update the earliest recorded date if history has been + // cleared. + this._hasEarliestRecord = false; + this.maybeUpdateEarliestRecordedDateTooltip(); + break; + } + }, + + /** + * Update the popup contents. Only called when the popup has been taken + * out of the template and is shown or about to be shown. + */ + refreshProtectionsPopup() { + let host = gIdentityHandler.getHostForDisplay(); + + // Push the appropriate strings out to the UI. + this._protectionsPopupMainViewHeaderLabel.textContent = + gNavigatorBundle.getFormattedString("protections.header", [host]); + + let currentlyEnabled = !this.hasException; + + for (let tpSwitch of [ + this._protectionsPopupTPSwitch, + this._protectionsPopupSiteNotWorkingTPSwitch, + ]) { + tpSwitch.toggleAttribute("enabled", currentlyEnabled); + } + + this._notBlockingWhyLink.setAttribute( + "tooltip", + currentlyEnabled + ? "protections-popup-not-blocking-why-etp-on-tooltip" + : "protections-popup-not-blocking-why-etp-off-tooltip" + ); + + // Toggle the breakage link according to the current enable state. + this.toggleBreakageLink(); + + // Give the button an accessible label for screen readers. + if (currentlyEnabled) { + this._protectionsPopupTPSwitch.setAttribute( + "aria-label", + gNavigatorBundle.getFormattedString("protections.disableAriaLabel", [ + host, + ]) + ); + } else { + this._protectionsPopupTPSwitch.setAttribute( + "aria-label", + gNavigatorBundle.getFormattedString("protections.enableAriaLabel", [ + host, + ]) + ); + } + + // Update the tooltip of the blocked tracker counter. + this.maybeUpdateEarliestRecordedDateTooltip(); + + let today = Date.now(); + let threeDaysMillis = 72 * 60 * 60 * 1000; + let expired = today - this.milestoneTimestampPref > threeDaysMillis; + + if (this._milestoneTextSet && !expired) { + this._protectionsPopup.setAttribute("milestone", this.milestonePref); + } else { + this._protectionsPopup.removeAttribute("milestone"); + } + + cookieBannerHandling.updateSection(); + + this._protectionsPopup.toggleAttribute("detected", this.anyDetected); + this._protectionsPopup.toggleAttribute("blocking", this.anyBlocking); + this._protectionsPopup.toggleAttribute("hasException", this.hasException); + }, + + /* + * This function sorts the category items into the Blocked/Allowed/None Detected + * sections. It's called immediately in onContentBlockingEvent if the popup + * is presently open. Otherwise, the next time the popup is shown. + */ + reorderCategoryItems() { + if (!this._categoryItemOrderInvalidated) { + return; + } + + delete this._categoryItemOrderInvalidated; + + // Hide all the headers to start with. + this._protectionsPopupBlockingHeader.hidden = true; + this._protectionsPopupNotBlockingHeader.hidden = true; + this._protectionsPopupNotFoundHeader.hidden = true; + + for (let { categoryItem } of Object.values(this.blockers)) { + if ( + categoryItem.classList.contains("notFound") || + categoryItem.hasAttribute("uidisabled") + ) { + // Add the item to the bottom of the list. This will be under + // the "None Detected" section. + categoryItem.parentNode.insertAdjacentElement( + "beforeend", + categoryItem + ); + categoryItem.setAttribute("disabled", true); + // We have an undetected category, show the header. + this._protectionsPopupNotFoundHeader.hidden = false; + continue; + } + + // Clear the disabled attribute in case we are moving the item out of + // "None Detected" + categoryItem.removeAttribute("disabled"); + + if (categoryItem.classList.contains("blocked") && !this.hasException) { + // Add the item just above the "Allowed" section - this will be the + // bottom of the "Blocked" section. + categoryItem.parentNode.insertBefore( + categoryItem, + this._protectionsPopupNotBlockingHeader + ); + // We have a blocking category, show the header. + this._protectionsPopupBlockingHeader.hidden = false; + continue; + } + + // Add the item just above the "None Detected" section - this will be the + // bottom of the "Allowed" section. + categoryItem.parentNode.insertBefore( + categoryItem, + this._protectionsPopupNotFoundHeader + ); + // We have an allowing category, show the header. + this._protectionsPopupNotBlockingHeader.hidden = false; + } + }, + + disableForCurrentPage(shouldReload = true) { + ContentBlockingAllowList.add(gBrowser.selectedBrowser); + if (shouldReload) { + this._hidePopup(); + BrowserReload(); + } + }, + + enableForCurrentPage(shouldReload = true) { + ContentBlockingAllowList.remove(gBrowser.selectedBrowser); + if (shouldReload) { + this._hidePopup(); + BrowserReload(); + } + }, + + async onTPSwitchCommand(event) { + // When the switch is clicked, we wait 500ms and then disable/enable + // protections, causing the page to refresh, and close the popup. + // We need to ensure we don't handle more clicks during the 500ms delay, + // so we keep track of state and return early if needed. + if (this._TPSwitchCommanding) { + return; + } + + this._TPSwitchCommanding = true; + + // Toggling the 'hasException' on the protections panel in order to do some + // styling after toggling the TP switch. + let newExceptionState = + this._protectionsPopup.toggleAttribute("hasException"); + for (let tpSwitch of [ + this._protectionsPopupTPSwitch, + this._protectionsPopupSiteNotWorkingTPSwitch, + ]) { + tpSwitch.toggleAttribute("enabled", !newExceptionState); + } + + // Toggle the breakage link if needed. + this.toggleBreakageLink(); + + // Change the tooltip of the tracking protection icon. + if (newExceptionState) { + this.showDisabledTooltipForTPIcon(); + } else { + this.showNoTrackerTooltipForTPIcon(); + } + + // Change the state of the tracking protection icon. + this.iconBox.toggleAttribute("hasException", newExceptionState); + + // Indicating that we need to show a toast after refreshing the page. + // And caching the current URI and window ID in order to only show the mini + // panel if it's still on the same page. + this._showToastAfterRefresh = true; + this._previousURI = gBrowser.currentURI.spec; + this._previousOuterWindowID = gBrowser.selectedBrowser.outerWindowID; + + if (newExceptionState) { + this.disableForCurrentPage(false); + this.recordClick("etp_toggle_off"); + } else { + this.enableForCurrentPage(false); + this.recordClick("etp_toggle_on"); + } + + // We need to flush the TP state change immediately without waiting the + // 500ms delay if the Tab get switched out. + let targetTab = gBrowser.selectedTab; + let onTabSelectHandler; + let tabSelectPromise = new Promise(resolve => { + onTabSelectHandler = () => resolve(); + gBrowser.tabContainer.addEventListener("TabSelect", onTabSelectHandler); + }); + let timeoutPromise = new Promise(resolve => setTimeout(resolve, 500)); + + await Promise.race([tabSelectPromise, timeoutPromise]); + gBrowser.tabContainer.removeEventListener("TabSelect", onTabSelectHandler); + PanelMultiView.hidePopup(this._protectionsPopup); + gBrowser.reloadTab(targetTab); + + delete this._TPSwitchCommanding; + }, + + onCookieBannerToggleCommand() { + cookieBannerHandling.onCookieBannerToggleCommand(); + }, + + setTrackersBlockedCounter(trackerCount) { + let forms = gNavigatorBundle.getString( + "protections.footer.blockedTrackerCounter.description" + ); + this._protectionsPopupTrackersCounterDescription.textContent = + PluralForm.get(trackerCount, forms).replace( + "#1", + trackerCount.toLocaleString(Services.locale.appLocalesAsBCP47) + ); + + // Show the counter if the number of tracker is not zero. + this._protectionsPopupTrackersCounterBox.toggleAttribute( + "showing", + trackerCount != 0 + ); + }, + + // Whenever one of the milestone prefs are changed, we attempt to update + // the milestone section string. This requires us to fetch the earliest + // recorded date from the Tracking DB, hence this process is async. + // When completed, we set _milestoneSetText to signal that the section + // is populated and ready to be shown - which happens next time we call + // refreshProtectionsPopup. + _milestoneTextSet: false, + async maybeSetMilestoneCounterText() { + if (!this._protectionsPopup) { + return; + } + let trackerCount = this.milestonePref; + if ( + !this.milestonesEnabledPref || + !trackerCount || + !this.milestoneListPref.includes(trackerCount) + ) { + this._milestoneTextSet = false; + return; + } + + let date = await TrackingDBService.getEarliestRecordedDate(); + let dateLocaleStr = new Date(date).toLocaleDateString("default", { + month: "long", + year: "numeric", + }); + + let desc = PluralForm.get( + trackerCount, + gNavigatorBundle.getString("protections.milestone.description") + ); + + this._protectionsPopupMilestonesText.textContent = desc + .replace("#1", gBrandBundle.GetStringFromName("brandShortName")) + .replace( + "#2", + trackerCount.toLocaleString(Services.locale.appLocalesAsBCP47) + ) + .replace("#3", dateLocaleStr); + + this._milestoneTextSet = true; + }, + + showDisabledTooltipForTPIcon() { + this._trackingProtectionIconTooltipLabel.textContent = + this.strings.disabledTooltipText; + this._trackingProtectionIconContainer.setAttribute( + "aria-label", + this.strings.disabledTooltipText + ); + }, + + showActiveTooltipForTPIcon() { + this._trackingProtectionIconTooltipLabel.textContent = + this.strings.activeTooltipText; + this._trackingProtectionIconContainer.setAttribute( + "aria-label", + this.strings.activeTooltipText + ); + }, + + showNoTrackerTooltipForTPIcon() { + this._trackingProtectionIconTooltipLabel.textContent = + this.strings.noTrackerTooltipText; + this._trackingProtectionIconContainer.setAttribute( + "aria-label", + this.strings.noTrackerTooltipText + ); + }, + + /** + * Showing the protections popup. + * + * @param {Object} options + * The object could have two properties. + * event: + * The event triggers the protections popup to be opened. + * toast: + * A boolean to indicate if we need to open the protections + * popup as a toast. A toast only has a header section and + * will be hidden after a certain amount of time. + */ + showProtectionsPopup(options = {}) { + const { event, toast } = options; + + this._initializePopup(); + + // Ensure we've updated category state based on the last blocking event: + if (this.hasOwnProperty("_lastEvent")) { + this.updatePanelForBlockingEvent(this._lastEvent); + delete this._lastEvent; + } + + // We need to clear the toast timer if it exists before showing the + // protections popup. + if (this._toastPanelTimer) { + clearTimeout(this._toastPanelTimer); + delete this._toastPanelTimer; + } + + this._protectionsPopup.toggleAttribute("toast", !!toast); + if (!toast) { + // Refresh strings if we want to open it as a standard protections popup. + this.refreshProtectionsPopup(); + } + + if (toast) { + this._protectionsPopup.addEventListener( + "popupshown", + () => { + this._toastPanelTimer = setTimeout(() => { + PanelMultiView.hidePopup(this._protectionsPopup, true); + delete this._toastPanelTimer; + }, this._protectionsPopupToastTimeout); + }, + { once: true } + ); + } + + // Add the "open" attribute to the tracking protection icon container + // for styling. + this._trackingProtectionIconContainer.setAttribute("open", "true"); + + // Check the panel state of other panels. Hide them if needed. + let openPanels = Array.from(document.querySelectorAll("panel[openpanel]")); + for (let panel of openPanels) { + PanelMultiView.hidePopup(panel); + } + + // Now open the popup, anchored off the primary chrome element + PanelMultiView.openPopup( + this._protectionsPopup, + this._trackingProtectionIconContainer, + { + position: "bottomleft topleft", + triggerEvent: event, + } + ).catch(console.error); + }, + + showSiteNotWorkingView() { + // Only show the Fonts item if we are restricting font visibility + if (this._fontVisibilityTrackingProtection >= 3) { + this._siteNotWorkingIssueListFonts.setAttribute("hidden", "true"); + } else { + this._siteNotWorkingIssueListFonts.removeAttribute("hidden"); + } + + this._protectionsPopupMultiView.showSubView( + "protections-popup-siteNotWorkingView" + ); + }, + + showSendReportView() { + // Save this URI to make sure that the user really only submits the location + // they see in the report breakage dialog. + this.reportURI = gBrowser.currentURI; + let urlWithoutQuery = this.reportURI.asciiSpec.replace( + "?" + this.reportURI.query, + "" + ); + let commentsTextarea = document.getElementById( + "protections-popup-sendReportView-collection-comments" + ); + commentsTextarea.value = ""; + this._protectionsPopupSendReportURL.value = urlWithoutQuery; + this._protectionsPopupSiteNotWorkingReportError.hidden = true; + this._protectionsPopupMultiView.showSubView( + "protections-popup-sendReportView" + ); + }, + + toggleBreakageLink() { + // The breakage link will only be shown if tracking protection is enabled + // for the site and the TP toggle state is on. And we won't show the + // link as toggling TP switch to On from Off. In order to do so, we need to + // know the previous TP state. We check the ContentBlockingAllowList instead + // of 'hasException' attribute of the protection popup for the previous + // since the 'hasException' will also be toggled as well as toggling the TP + // switch. We won't be able to know the previous TP state through the + // 'hasException' attribute. So we fallback to check the + // ContentBlockingAllowList here. + this._protectionsPopupTPSwitchBreakageLink.hidden = + ContentBlockingAllowList.includes(gBrowser.selectedBrowser) || + !this.anyBlocking || + !this._protectionsPopupTPSwitch.hasAttribute("enabled"); + // The "Site Fixed?" link behaves similarly but for the opposite state. + this._protectionsPopupTPSwitchBreakageFixedLink.hidden = + !ContentBlockingAllowList.includes(gBrowser.selectedBrowser) || + this._protectionsPopupTPSwitch.hasAttribute("enabled"); + }, + + submitBreakageReport(uri) { + let reportEndpoint = Services.prefs.getStringPref( + this.PREF_REPORT_BREAKAGE_URL + ); + if (!reportEndpoint) { + return; + } + + let commentsTextarea = document.getElementById( + "protections-popup-sendReportView-collection-comments" + ); + + let formData = new FormData(); + formData.set("title", uri.host); + + // Leave the ? at the end of the URL to signify that this URL had its query stripped. + let urlWithoutQuery = uri.asciiSpec.replace(uri.query, ""); + let body = `Full URL: ${urlWithoutQuery}\n`; + body += `userAgent: ${navigator.userAgent}\n`; + + body += "\n**Preferences**\n"; + body += `${TrackingProtection.prefEnabled}: ${Services.prefs.getBoolPref( + TrackingProtection.prefEnabled + )}\n`; + body += `${ + TrackingProtection.prefEnabledInPrivateWindows + }: ${Services.prefs.getBoolPref( + TrackingProtection.prefEnabledInPrivateWindows + )}\n`; + body += `urlclassifier.trackingTable: ${Services.prefs.getStringPref( + "urlclassifier.trackingTable" + )}\n`; + body += `network.http.referer.defaultPolicy: ${Services.prefs.getIntPref( + "network.http.referer.defaultPolicy" + )}\n`; + body += `network.http.referer.defaultPolicy.pbmode: ${Services.prefs.getIntPref( + "network.http.referer.defaultPolicy.pbmode" + )}\n`; + body += `${ThirdPartyCookies.prefEnabled}: ${Services.prefs.getIntPref( + ThirdPartyCookies.prefEnabled + )}\n`; + body += `privacy.annotate_channels.strict_list.enabled: ${Services.prefs.getBoolPref( + "privacy.annotate_channels.strict_list.enabled" + )}\n`; + body += `privacy.restrict3rdpartystorage.expiration: ${Services.prefs.getIntPref( + "privacy.restrict3rdpartystorage.expiration" + )}\n`; + body += `${Fingerprinting.prefEnabled}: ${Services.prefs.getBoolPref( + Fingerprinting.prefEnabled + )}\n`; + body += `${Cryptomining.prefEnabled}: ${Services.prefs.getBoolPref( + Cryptomining.prefEnabled + )}\n`; + body += `\nhasException: ${this.hasException}\n`; + + body += "\n**Comments**\n" + commentsTextarea.value; + + formData.set("body", body); + + let activatedBlockers = []; + for (let blocker of Object.values(this.blockers)) { + if (blocker.activated) { + activatedBlockers.push(blocker.reportBreakageLabel); + } + } + + formData.set("labels", activatedBlockers.join(",")); + + this._protectionsPopupSendReportButton.disabled = true; + + fetch(reportEndpoint, { + method: "POST", + credentials: "omit", + body: formData, + }) + .then(response => { + this._protectionsPopupSendReportButton.disabled = false; + if (!response.ok) { + console.error( + `Content Blocking report to ${reportEndpoint} failed with status ${response.status}` + ); + this._protectionsPopupSiteNotWorkingReportError.hidden = false; + } else { + this._protectionsPopup.hidePopup(); + ConfirmationHint.show( + this.iconBox, + "confirmation-hint-breakage-report-sent" + ); + } + }) + .catch(console.error); + }, + + onSendReportClicked() { + this.submitBreakageReport(this.reportURI); + }, + + async maybeUpdateEarliestRecordedDateTooltip() { + // If we've already updated or the popup isn't in the DOM yet, don't bother + // doing this: + if (this._hasEarliestRecord || !this._protectionsPopup) { + return; + } + + let date = await TrackingDBService.getEarliestRecordedDate(); + + // If there is no record for any blocked tracker, we don't have to do anything + // since the tracker counter won't be shown. + if (!date) { + return; + } + this._hasEarliestRecord = true; + + const dateLocaleStr = new Date(date).toLocaleDateString("default", { + month: "long", + day: "numeric", + year: "numeric", + }); + + const tooltipStr = gNavigatorBundle.getFormattedString( + "protections.footer.blockedTrackerCounter.tooltip", + [dateLocaleStr] + ); + + this._protectionsPopupTrackersCounterDescription.setAttribute( + "tooltiptext", + tooltipStr + ); + }, +}; |