360 lines
11 KiB
JavaScript
360 lines
11 KiB
JavaScript
/* 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/. */
|
||
|
||
export function UrlClassifierExceptionListService() {}
|
||
|
||
const lazy = {};
|
||
|
||
ChromeUtils.defineESModuleGetters(lazy, {
|
||
RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
|
||
});
|
||
|
||
const COLLECTION_NAME = "url-classifier-exceptions";
|
||
|
||
/**
|
||
* Tracks URL classifier exception list entries for a single named feature.
|
||
*/
|
||
class Feature {
|
||
constructor(name, prefName) {
|
||
this.name = name;
|
||
this.prefName = prefName;
|
||
this.observers = new Set();
|
||
this.prefValue = null;
|
||
this.remoteEntries = null;
|
||
|
||
if (prefName) {
|
||
this.prefValue = Services.prefs.getStringPref(this.prefName, null);
|
||
Services.prefs.addObserver(prefName, this);
|
||
}
|
||
}
|
||
|
||
async addAndRunObserver(observer) {
|
||
this.observers.add(observer);
|
||
this.notifyObservers(observer);
|
||
}
|
||
|
||
removeObserver(observer) {
|
||
this.observers.delete(observer);
|
||
}
|
||
|
||
observe(subject, topic, data) {
|
||
if (topic != "nsPref:changed" || data != this.prefName) {
|
||
console.error(`Unexpected event ${topic} with ${data}`);
|
||
return;
|
||
}
|
||
|
||
this.prefValue = Services.prefs.getStringPref(this.prefName, null);
|
||
this.notifyObservers();
|
||
}
|
||
|
||
onRemoteSettingsUpdate(entries) {
|
||
this.remoteEntries = [];
|
||
|
||
for (let jsEntry of entries) {
|
||
let { classifierFeatures } = jsEntry;
|
||
if (classifierFeatures.includes(this.name)) {
|
||
let entry = Feature.rsObjectToEntry(jsEntry);
|
||
if (entry) {
|
||
this.remoteEntries.push(entry);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Convert a JS object from RemoteSettings to an nsIUrlClassifierExceptionListEntry.
|
||
*
|
||
* @param {object} rsObject - The JS object from RemoteSettings to convert.
|
||
* @returns {nsIUrlClassifierExceptionListEntry} The converted nsIUrlClassifierExceptionListEntry.
|
||
*/
|
||
static rsObjectToEntry(rsObject) {
|
||
let entry = Cc[
|
||
"@mozilla.org/url-classifier/exception-list-entry;1"
|
||
].createInstance(Ci.nsIUrlClassifierExceptionListEntry);
|
||
|
||
let {
|
||
category: categoryStr,
|
||
urlPattern,
|
||
topLevelUrlPattern = "",
|
||
isPrivateBrowsingOnly = false,
|
||
filterContentBlockingCategories = [],
|
||
classifierFeatures = [],
|
||
} = rsObject;
|
||
|
||
const CATEGORY_STR_TO_ENUM = {
|
||
"internal-pref":
|
||
Ci.nsIUrlClassifierExceptionListEntry.CATEGORY_INTERNAL_PREF,
|
||
baseline: Ci.nsIUrlClassifierExceptionListEntry.CATEGORY_BASELINE,
|
||
convenience: Ci.nsIUrlClassifierExceptionListEntry.CATEGORY_CONVENIENCE,
|
||
};
|
||
|
||
let category = CATEGORY_STR_TO_ENUM[categoryStr];
|
||
if (category == null) {
|
||
console.error(
|
||
"Invalid or unknown category",
|
||
{ rsObject },
|
||
{ categories: Object.keys(CATEGORY_STR_TO_ENUM) }
|
||
);
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
entry.init(
|
||
category,
|
||
urlPattern,
|
||
topLevelUrlPattern,
|
||
isPrivateBrowsingOnly,
|
||
filterContentBlockingCategories,
|
||
classifierFeatures
|
||
);
|
||
} catch (e) {
|
||
console.error(
|
||
"Error initializing url classifier exception list entry " + e.message,
|
||
e,
|
||
{ rsObject }
|
||
);
|
||
return null;
|
||
}
|
||
|
||
return entry;
|
||
}
|
||
|
||
notifyObservers(observer = null) {
|
||
let entries = [];
|
||
if (this.prefValue) {
|
||
for (let prefEntry of this.prefValue.split(",")) {
|
||
let entry = Feature.rsObjectToEntry({
|
||
category: "internal-pref",
|
||
urlPattern: prefEntry,
|
||
classifierFeatures: [this.name],
|
||
});
|
||
if (entry) {
|
||
entries.push(entry);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (this.remoteEntries) {
|
||
for (let entry of this.remoteEntries) {
|
||
entries.push(entry);
|
||
}
|
||
}
|
||
|
||
// Construct nsIUrlClassifierExceptionList with all entries that belong to
|
||
// this feature.
|
||
let list = Cc[
|
||
"@mozilla.org/url-classifier/exception-list;1"
|
||
].createInstance(Ci.nsIUrlClassifierExceptionList);
|
||
for (let entry of entries) {
|
||
try {
|
||
list.addEntry(entry);
|
||
} catch (e) {
|
||
console.error(
|
||
"Error adding url classifier exception list entry " + e.message,
|
||
e,
|
||
entry
|
||
);
|
||
}
|
||
}
|
||
|
||
if (observer) {
|
||
observer.onExceptionListUpdate(list);
|
||
} else {
|
||
for (let obs of this.observers) {
|
||
obs.onExceptionListUpdate(list);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
UrlClassifierExceptionListService.prototype = {
|
||
classID: Components.ID("{b9f4fd03-9d87-4bfd-9958-85a821750ddc}"),
|
||
QueryInterface: ChromeUtils.generateQI([
|
||
"nsIUrlClassifierExceptionListService",
|
||
"nsIObserver",
|
||
]),
|
||
|
||
features: {},
|
||
_initialized: false,
|
||
|
||
ETP_PREFERENCES: [
|
||
"privacy.trackingprotection.allow_list.baseline.enabled",
|
||
"privacy.trackingprotection.allow_list.convenience.enabled",
|
||
"browser.contentblocking.category",
|
||
],
|
||
PREF_ALLOW_LIST_USER_INTERACTED:
|
||
"privacy.trackingprotection.allow_list.hasUserInteractedWithETPSettings",
|
||
|
||
observe(subject, topic, data) {
|
||
if (topic === "idle-daily") {
|
||
const baseline = Services.prefs.getBoolPref(
|
||
"privacy.trackingprotection.allow_list.baseline.enabled"
|
||
);
|
||
const convenience = Services.prefs.getBoolPref(
|
||
"privacy.trackingprotection.allow_list.convenience.enabled"
|
||
);
|
||
Glean.contentblocking.tpAllowlistBaselineEnabled.set(baseline);
|
||
// If baseline is false, having convenience as true has no effect, so we treat it as false.
|
||
Glean.contentblocking.tpAllowlistConvenienceEnabled.set(
|
||
baseline ? convenience : false
|
||
);
|
||
}
|
||
if (topic === "nsPref:changed") {
|
||
// If the user changes the baseline, convenience, or category preference, we set
|
||
// hasUserInteractedWithETP to true to indicate interaction with ETP settings.
|
||
// This lets us skip the infobar prompting users to enable allowlists if they’ve
|
||
// already made a choice.
|
||
if (this.ETP_PREFERENCES.includes(data)) {
|
||
Services.prefs.setBoolPref(this.PREF_ALLOW_LIST_USER_INTERACTED, true);
|
||
}
|
||
}
|
||
},
|
||
|
||
async lazyInit() {
|
||
if (this._initialized) {
|
||
return;
|
||
}
|
||
|
||
this.maybeMigrateCategoryPrefs();
|
||
|
||
// Add ETP preference observers AFTER migration to avoid false positives. The migration function
|
||
// above may programmatically change ETP preferences, which would incorrectly trigger our user
|
||
// interaction tracking if observers were already installed. By adding observers after
|
||
// migration, we ensure we only detect user changes to ETP settings.
|
||
this.addETPUserInteractionPrefObservers();
|
||
|
||
let rs = lazy.RemoteSettings(COLLECTION_NAME);
|
||
rs.on("sync", event => {
|
||
let {
|
||
data: { current },
|
||
} = event;
|
||
this.entries = current || [];
|
||
this.onUpdateEntries(current);
|
||
});
|
||
|
||
this._initialized = true;
|
||
|
||
// If the remote settings list hasn't been populated yet we have to make sure
|
||
// to do it before firing the first notification.
|
||
// This has to be run after _initialized is set because we'll be
|
||
// blocked while getting entries from RemoteSetting, and we don't want
|
||
// LazyInit is executed again.
|
||
try {
|
||
// The data will be initially available from the local DB (via a
|
||
// resource:// URI).
|
||
this.entries = await rs.get();
|
||
} catch (e) {}
|
||
|
||
// RemoteSettings.get() could return null, ensure passing a list to
|
||
// onUpdateEntries.
|
||
if (!this.entries) {
|
||
this.entries = [];
|
||
}
|
||
|
||
this.onUpdateEntries(this.entries);
|
||
},
|
||
|
||
/**
|
||
* Runs migration code for the allow-list category prefs.
|
||
* Users who have ETP "strict" or "custom" enabled should not automatically
|
||
* get enrolled into the new allow-list categories. Instead they should have
|
||
* the opportunity to opt in/out via the preferences UI.
|
||
*/
|
||
maybeMigrateCategoryPrefs() {
|
||
const ALLOW_LIST_CATEGORY_MIGRATION_PREF =
|
||
"privacy.trackingprotection.allow_list.hasMigratedCategoryPrefs";
|
||
|
||
if (Services.prefs.getBoolPref(ALLOW_LIST_CATEGORY_MIGRATION_PREF, false)) {
|
||
// Already migrated.
|
||
return;
|
||
}
|
||
|
||
// Set the migration pref to true so we only run the migration once.
|
||
Services.prefs.setBoolPref(ALLOW_LIST_CATEGORY_MIGRATION_PREF, true);
|
||
|
||
// This pref is set on both Desktop and Fenix (Bug 1956620).
|
||
let cbCategory = Services.prefs.getStringPref(
|
||
"browser.contentblocking.category",
|
||
"standard"
|
||
);
|
||
// Don't migrate if the user is using the default category. The default
|
||
// category pref states are already correct.
|
||
if (cbCategory == "standard") {
|
||
return;
|
||
}
|
||
|
||
// cbCategory is either "strict" or "custom". Disable both allow list
|
||
// categories.
|
||
Services.prefs.setBoolPref(
|
||
"privacy.trackingprotection.allow_list.baseline.enabled",
|
||
false
|
||
);
|
||
Services.prefs.setBoolPref(
|
||
"privacy.trackingprotection.allow_list.convenience.enabled",
|
||
false
|
||
);
|
||
},
|
||
|
||
onUpdateEntries(entries) {
|
||
for (let key of Object.keys(this.features)) {
|
||
let feature = this.features[key];
|
||
feature.onRemoteSettingsUpdate(entries);
|
||
feature.notifyObservers();
|
||
}
|
||
},
|
||
|
||
registerAndRunExceptionListObserver(feature, prefName, observer) {
|
||
// We don't await this; the caller is C++ and won't await this function,
|
||
// and because we prevent re-entering into this method, once it's been
|
||
// called once any subsequent calls will early-return anyway - so
|
||
// awaiting that would be meaningless. Instead, `Feature` implementations
|
||
// make sure not to call into observers until they have data, and we
|
||
// make sure to let feature instances know whether we have data
|
||
// immediately.
|
||
this.lazyInit();
|
||
|
||
if (!this.features[feature]) {
|
||
let featureObj = new Feature(feature, prefName);
|
||
this.features[feature] = featureObj;
|
||
// If we've previously initialized, we need to pass the entries
|
||
// we already have to the new feature.
|
||
if (this.entries) {
|
||
featureObj.onRemoteSettingsUpdate(this.entries);
|
||
}
|
||
}
|
||
this.features[feature].addAndRunObserver(observer);
|
||
},
|
||
|
||
unregisterExceptionListObserver(feature, observer) {
|
||
if (!this.features[feature]) {
|
||
return;
|
||
}
|
||
this.features[feature].removeObserver(observer);
|
||
},
|
||
|
||
/**
|
||
* Adds preference observers to track user interactions with ETP settings.
|
||
* These observers monitor changes to the baseline allow list, convenience allow list, and
|
||
* content blocking category preferences to detect when users modify ETP-related settings.
|
||
*/
|
||
addETPUserInteractionPrefObservers() {
|
||
this.ETP_PREFERENCES.forEach(pref => {
|
||
Services.prefs.addObserver(pref, this.observe.bind(this));
|
||
});
|
||
},
|
||
|
||
removeETPUserInteractionPrefObservers() {
|
||
this.ETP_PREFERENCES.forEach(pref => {
|
||
Services.prefs.removeObserver(pref, this.observe.bind(this));
|
||
});
|
||
},
|
||
|
||
clear() {
|
||
this.features = {};
|
||
this._initialized = false;
|
||
this.entries = null;
|
||
this.removeETPUserInteractionPrefObservers();
|
||
},
|
||
};
|