Add a JS RemoteSettings client that fetches filter list attachments from the "content-classifier-lists" collection and pushes them to the C++ ContentClassifierService via new XPCOM interfaces. Lists are selected for blocking or annotation via comma-separated list_names prefs. The RS client is only initialized when the feature is enabled and list names are configured, ensuring no downloads occur when the feature is disabled. Attachments are cached with fallback to cache and dump. Differential Revision: https://phabricator.services.mozilla.com/D291513
217 lines
6.6 KiB
JavaScript
217 lines
6.6 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/. */
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
|
|
});
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "log", () => {
|
|
return console.createInstance({
|
|
maxLogLevelPref:
|
|
"privacy.trackingprotection.content.remote_settings.loglevel",
|
|
prefix: "ContentClassifier",
|
|
});
|
|
});
|
|
|
|
const COLLECTION_NAME = "content-classifier-lists";
|
|
|
|
/**
|
|
* RemoteSettings client that fetches content classifier filter lists
|
|
* and pushes them to the C++ ContentClassifierService.
|
|
*/
|
|
export class ContentClassifierRemoteSettingsClient {
|
|
classID = Components.ID("{C7DDDBF2-8BC4-41A1-AC90-5144BEC5ABDF}");
|
|
QueryInterface = ChromeUtils.generateQI([
|
|
"nsIContentClassifierRemoteSettingsClient",
|
|
]);
|
|
|
|
#rs = null;
|
|
#onSyncCallback = null;
|
|
#service = null;
|
|
#initialized = false;
|
|
|
|
constructor() {
|
|
this.#rs = lazy.RemoteSettings(COLLECTION_NAME);
|
|
}
|
|
|
|
/**
|
|
* Initialize the client, import all existing lists, and register a
|
|
* sync listener for future updates. Resolves once the initial import
|
|
* has finished (or failed). Safe to call more than once; subsequent
|
|
* calls resolve immediately without re-importing.
|
|
*
|
|
* The service keeps a strong reference to this client via mRSClient
|
|
* and we keep a strong reference back via #service. The resulting
|
|
* cycle is broken explicitly when the service calls shutdown().
|
|
*
|
|
* @param {nsIContentClassifierService} service
|
|
* The C++ service to push filter list data to.
|
|
*/
|
|
async init(service) {
|
|
if (!service) {
|
|
throw new Error("Missing required argument service");
|
|
}
|
|
if (this.#initialized) {
|
|
lazy.log.debug(`init: already initialized`);
|
|
return;
|
|
}
|
|
this.#initialized = true;
|
|
this.#service = service;
|
|
|
|
lazy.log.debug(`init: starting import`);
|
|
// Register the sync listener before the async import so a sync event
|
|
// that fires during the import is not lost.
|
|
this.#onSyncCallback = this.onSync.bind(this);
|
|
this.#rs.on("sync", this.#onSyncCallback);
|
|
await this.importAllLists();
|
|
lazy.log.debug(`init: done, sync listener registered`);
|
|
}
|
|
|
|
/**
|
|
* Shut down the client and unregister the sync listener.
|
|
*/
|
|
shutdown() {
|
|
lazy.log.debug(`shutdown`);
|
|
if (this.#onSyncCallback) {
|
|
this.#rs.off("sync", this.#onSyncCallback);
|
|
this.#onSyncCallback = null;
|
|
}
|
|
this.#service = null;
|
|
this.#initialized = false;
|
|
}
|
|
|
|
/**
|
|
* Handle a RemoteSettings sync event by applying record changes
|
|
* to the content classifier service.
|
|
*
|
|
* @param {object} event
|
|
* @param {object} event.data
|
|
* @param {Array} event.data.created Newly added records.
|
|
* @param {Array} event.data.updated Records with {old, new} pairs.
|
|
* @param {Array} event.data.deleted Removed records.
|
|
*/
|
|
async onSync({ data: { created = [], updated = [], deleted = [] } }) {
|
|
let service = this.#service;
|
|
if (!service) {
|
|
return;
|
|
}
|
|
|
|
lazy.log.debug(
|
|
`onSync: ${created.length} created, ${updated.length} updated, ${deleted.length} deleted`
|
|
);
|
|
|
|
for (let record of deleted) {
|
|
try {
|
|
await this.#rs.attachments.deleteDownloaded(record);
|
|
} catch (e) {
|
|
lazy.log.error(`Failed to delete attachment for "${record.Name}":`, e);
|
|
}
|
|
service.removeFilterList(record.Name);
|
|
lazy.log.info(`Removed list "${record.Name}"`);
|
|
}
|
|
|
|
let updateResults = await Promise.all(
|
|
updated.map(async ({ new: newRecord }) => {
|
|
// The attachment cache is keyed by record id, and old/new have
|
|
// the same id for updated records, so download() overwrites the
|
|
// stale cached attachment. No explicit deleteDownloaded needed.
|
|
let ok = await this.#downloadAndStore(service, newRecord);
|
|
if (ok) {
|
|
lazy.log.info(`Updated list "${newRecord.Name}"`);
|
|
}
|
|
return ok;
|
|
})
|
|
);
|
|
|
|
let createResults = await Promise.all(
|
|
created.map(async record => {
|
|
let ok = await this.#downloadAndStore(service, record);
|
|
if (ok) {
|
|
lazy.log.info(`Added list "${record.Name}"`);
|
|
}
|
|
return ok;
|
|
})
|
|
);
|
|
|
|
// Always apply so that `deleted` takes effect even if all
|
|
// downloads failed. For updates/creates, applyFilterLists will
|
|
// simply rebuild from whatever stored data survived.
|
|
service.applyFilterLists();
|
|
|
|
let failureCount =
|
|
updateResults.filter(ok => !ok).length +
|
|
createResults.filter(ok => !ok).length;
|
|
if (failureCount) {
|
|
lazy.log.warn(`onSync: ${failureCount} record(s) failed to download`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch all records from the collection, download their attachments,
|
|
* and push the data to the C++ service.
|
|
*/
|
|
async importAllLists() {
|
|
let service = this.#service;
|
|
if (!service) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let records = await this.#rs.get();
|
|
lazy.log.debug(`importAllLists: got ${records.length} records`);
|
|
|
|
if (records.length) {
|
|
let results = await Promise.all(
|
|
records.map(record => this.#downloadAndStore(service, record))
|
|
);
|
|
let failureCount = results.filter(ok => !ok).length;
|
|
if (failureCount) {
|
|
lazy.log.warn(
|
|
`importAllLists: ${failureCount} record(s) failed to download`
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
lazy.log.error(`Error importing lists:`, error);
|
|
} finally {
|
|
// Always apply, even on total failure, so callers waiting on the
|
|
// "lists loaded" notification don't hang.
|
|
service.applyFilterLists();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download a record's attachment and push it to the service.
|
|
*
|
|
* @param {nsIContentClassifierService} service
|
|
* @param {object} record A RemoteSettings record with an attachment.
|
|
* @returns {Promise<boolean>}
|
|
* true if the data was successfully stored, false otherwise.
|
|
*/
|
|
async #downloadAndStore(service, record) {
|
|
if (!record.attachment) {
|
|
lazy.log.warn(`Record "${record.Name}" has no attachment`);
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
let result = await this.#rs.attachments.download(record, {
|
|
fallbackToCache: true,
|
|
fallbackToDump: true,
|
|
});
|
|
let bytes = new Uint8Array(result.buffer);
|
|
service.setFilterListData(record.Name, bytes);
|
|
lazy.log.debug(
|
|
`Downloaded and stored "${record.Name}" (${bytes.length} bytes)`
|
|
);
|
|
return true;
|
|
} catch (e) {
|
|
lazy.log.error(`Failed to download attachment for "${record.Name}":`, e);
|
|
return false;
|
|
}
|
|
}
|
|
}
|