/* 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/. */ import { ContentPref, cbHandleCompletion, cbHandleError, cbHandleResult, } from "resource://gre/modules/ContentPrefUtils.sys.mjs"; import { ContentPrefStore } from "resource://gre/modules/ContentPrefStore.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { Sqlite: "resource://gre/modules/Sqlite.sys.mjs", }); const CACHE_MAX_GROUP_ENTRIES = 100; const GROUP_CLAUSE = ` SELECT id FROM groups WHERE name = :group OR (:includeSubdomains AND name LIKE :pattern ESCAPE '/') `; export function ContentPrefService2() { if (Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_CONTENT) { return ChromeUtils.importESModule( "resource://gre/modules/ContentPrefServiceChild.sys.mjs" ).ContentPrefServiceChild; } Services.obs.addObserver(this, "last-pb-context-exited"); // Observe shutdown so we can shut down the database connection. Services.obs.addObserver(this, "profile-before-change"); } const cache = new ContentPrefStore(); cache.set = function CPS_cache_set() { Object.getPrototypeOf(this).set.apply(this, arguments); let groupCount = this._groups.size; if (groupCount >= CACHE_MAX_GROUP_ENTRIES) { // Clean half of the entries for (let [group, name] of this) { this.remove(group, name); groupCount--; if (groupCount < CACHE_MAX_GROUP_ENTRIES / 2) { break; } } } }; const privModeStorage = new ContentPrefStore(); function executeStatementsInTransaction(conn, stmts) { return conn.executeTransaction(async () => { let rows = []; for (let { sql, params, cachable } of stmts) { let execute = cachable ? conn.executeCached : conn.execute; let stmtRows = await execute.call(conn, sql, params); rows = rows.concat(stmtRows); } return rows; }); } /** * Helper function to extract a non-empty group from a URI. * @param {nsIURI} uri The URI to extract from. * @returns {string} a non-empty group. * @throws if a non-empty group cannot be extracted. */ function nonEmptyGroupFromURI(uri) { if (uri.schemeIs("blob")) { // blob: URLs are generated internally for a specific browser instance, // thus storing settings for them would be pointless. Though in most cases // it's possible to extract an origin from them and use it as the group. let embeddedURL = new URL(URL.fromURI(uri).origin); if (/^https?:$/.test(embeddedURL.protocol)) { return embeddedURL.host; } if (embeddedURL.origin) { // Keep the protocol if it's not http(s), to avoid mixing up settings // for different protocols, e.g. resource://moz.com and https://moz.com. return embeddedURL.origin; } } if (uri.host) { // Accessing the host property of the URI will throw an exception // if the URI is of a type that doesn't have a host property. // Otherwise, we manually throw an exception if the host is empty, // since the effect is the same (we can't derive a group from it). return uri.host; } // If reach this point, we'd have an empty group. throw new Error(`Can't derive non-empty CPS group from ${uri.spec}`); } function HostnameGrouper_group(aURI) { try { return nonEmptyGroupFromURI(aURI); } catch (ex) { // If we don't have a host, then use the entire URI (minus the query, // reference, and hash, if possible) as the group. This means that URIs // like about:mozilla and about:blank will be considered separate groups, // but at least they'll be grouped somehow. // This also means that each individual file: URL will be considered // its own group. This seems suboptimal, but so does treating the entire // file: URL space as a single group (especially if folks start setting // group-specific capabilities prefs). // XXX Is there something better we can do here? try { return aURI.prePath + aURI.filePath; } catch (ex) { return aURI.spec; } } } ContentPrefService2.prototype = { // XPCOM Plumbing classID: Components.ID("{e3f772f3-023f-4b32-b074-36cf0fd5d414}"), // Destruction _destroy() { Services.obs.removeObserver(this, "profile-before-change"); Services.obs.removeObserver(this, "last-pb-context-exited"); // Delete references to XPCOM components to make sure we don't leak them // (although we haven't observed leakage in tests). Also delete references // in _observers and _genericObservers to avoid cycles with those that // refer to us and don't remove themselves from those observer pools. delete this._observers; delete this._genericObservers; }, // in-memory cache and private-browsing stores _cache: cache, _pbStore: privModeStorage, _connPromise: null, get conn() { if (this._connPromise) { return this._connPromise; } return (this._connPromise = (async () => { let conn; try { conn = await this._getConnection(); } catch (e) { this.log("Failed to establish database connection: " + e); throw e; } return conn; })()); }, // nsIContentPrefService getByName: function CPS2_getByName(name, context, callback) { checkNameArg(name); checkCallbackArg(callback, true); // Some prefs may be in both the database and the private browsing store. // Notify the caller of such prefs only once, using the values from private // browsing. let pbPrefs = new ContentPrefStore(); if (context && context.usePrivateBrowsing) { for (let [sgroup, sname, val] of this._pbStore) { if (sname == name) { pbPrefs.set(sgroup, sname, val); } } } let stmt1 = this._stmt(` SELECT groups.name AS grp, prefs.value AS value FROM prefs JOIN settings ON settings.id = prefs.settingID JOIN groups ON groups.id = prefs.groupID WHERE settings.name = :name `); stmt1.params.name = name; let stmt2 = this._stmt(` SELECT NULL AS grp, prefs.value AS value FROM prefs JOIN settings ON settings.id = prefs.settingID WHERE settings.name = :name AND prefs.groupID ISNULL `); stmt2.params.name = name; this._execStmts([stmt1, stmt2], { onRow: row => { let grp = row.getResultByName("grp"); let val = row.getResultByName("value"); this._cache.set(grp, name, val); if (!pbPrefs.has(grp, name)) { cbHandleResult(callback, new ContentPref(grp, name, val)); } }, onDone: (reason, ok) => { if (ok) { for (let [pbGroup, pbName, pbVal] of pbPrefs) { cbHandleResult(callback, new ContentPref(pbGroup, pbName, pbVal)); } } cbHandleCompletion(callback, reason); }, onError: nsresult => { cbHandleError(callback, nsresult); }, }); }, getByDomainAndName: function CPS2_getByDomainAndName( group, name, context, callback ) { checkGroupArg(group); this._get(group, name, false, context, callback); }, getBySubdomainAndName: function CPS2_getBySubdomainAndName( group, name, context, callback ) { checkGroupArg(group); this._get(group, name, true, context, callback); }, getGlobal: function CPS2_getGlobal(name, context, callback) { this._get(null, name, false, context, callback); }, _get: function CPS2__get(group, name, includeSubdomains, context, callback) { group = this._parseGroup(group); checkNameArg(name); checkCallbackArg(callback, true); // Some prefs may be in both the database and the private browsing store. // Notify the caller of such prefs only once, using the values from private // browsing. let pbPrefs = new ContentPrefStore(); if (context && context.usePrivateBrowsing) { for (let [sgroup, val] of this._pbStore.match( group, name, includeSubdomains )) { pbPrefs.set(sgroup, name, val); } } this._execStmts([this._commonGetStmt(group, name, includeSubdomains)], { onRow: row => { let grp = row.getResultByName("grp"); let val = row.getResultByName("value"); this._cache.set(grp, name, val); if (!pbPrefs.has(group, name)) { cbHandleResult(callback, new ContentPref(grp, name, val)); } }, onDone: (reason, ok, gotRow) => { if (ok) { if (!gotRow) { this._cache.set(group, name, undefined); } for (let [pbGroup, pbName, pbVal] of pbPrefs) { cbHandleResult(callback, new ContentPref(pbGroup, pbName, pbVal)); } } cbHandleCompletion(callback, reason); }, onError: nsresult => { cbHandleError(callback, nsresult); }, }); }, _commonGetStmt: function CPS2__commonGetStmt(group, name, includeSubdomains) { let stmt = group ? this._stmtWithGroupClause( group, includeSubdomains, ` SELECT groups.name AS grp, prefs.value AS value FROM prefs JOIN settings ON settings.id = prefs.settingID JOIN groups ON groups.id = prefs.groupID WHERE settings.name = :name AND prefs.groupID IN (${GROUP_CLAUSE}) ` ) : this._stmt(` SELECT NULL AS grp, prefs.value AS value FROM prefs JOIN settings ON settings.id = prefs.settingID WHERE settings.name = :name AND prefs.groupID ISNULL `); stmt.params.name = name; return stmt; }, _stmtWithGroupClause: function CPS2__stmtWithGroupClause( group, includeSubdomains, sql ) { let stmt = this._stmt(sql, false); stmt.params.group = group; stmt.params.includeSubdomains = includeSubdomains || false; stmt.params.pattern = "%." + (group == null ? null : group.replace(/\/|%|_/g, "/$&")); return stmt; }, getCachedByDomainAndName: function CPS2_getCachedByDomainAndName( group, name, context ) { checkGroupArg(group); let prefs = this._getCached(group, name, false, context); return prefs[0] || null; }, getCachedBySubdomainAndName: function CPS2_getCachedBySubdomainAndName( group, name, context ) { checkGroupArg(group); return this._getCached(group, name, true, context); }, getCachedGlobal: function CPS2_getCachedGlobal(name, context) { let prefs = this._getCached(null, name, false, context); return prefs[0] || null; }, _getCached: function CPS2__getCached( group, name, includeSubdomains, context ) { group = this._parseGroup(group); checkNameArg(name); let storesToCheck = [this._cache]; if (context && context.usePrivateBrowsing) { storesToCheck.push(this._pbStore); } let outStore = new ContentPrefStore(); storesToCheck.forEach(function (store) { for (let [sgroup, val] of store.match(group, name, includeSubdomains)) { outStore.set(sgroup, name, val); } }); let prefs = []; for (let [sgroup, sname, val] of outStore) { prefs.push(new ContentPref(sgroup, sname, val)); } return prefs; }, set: function CPS2_set(group, name, value, context, callback) { checkGroupArg(group); this._set(group, name, value, context, callback); }, setGlobal: function CPS2_setGlobal(name, value, context, callback) { this._set(null, name, value, context, callback); }, _set: function CPS2__set(group, name, value, context, callback) { group = this._parseGroup(group); checkNameArg(name); checkValueArg(value); checkCallbackArg(callback, false); if (context && context.usePrivateBrowsing) { this._pbStore.set(group, name, value); this._schedule(function () { cbHandleCompletion(callback, Ci.nsIContentPrefCallback2.COMPLETE_OK); this._notifyPrefSet(group, name, value, context.usePrivateBrowsing); }); return; } // Invalidate the cached value so consumers accessing the cache between now // and when the operation finishes don't get old data. this._cache.remove(group, name); let stmts = []; // Create the setting if it doesn't exist. let stmt = this._stmt(` INSERT OR IGNORE INTO settings (id, name) VALUES((SELECT id FROM settings WHERE name = :name), :name) `); stmt.params.name = name; stmts.push(stmt); // Create the group if it doesn't exist. if (group) { stmt = this._stmt(` INSERT OR IGNORE INTO groups (id, name) VALUES((SELECT id FROM groups WHERE name = :group), :group) `); stmt.params.group = group; stmts.push(stmt); } // Finally create or update the pref. if (group) { stmt = this._stmt(` INSERT OR REPLACE INTO prefs (id, groupID, settingID, value, timestamp) VALUES( (SELECT prefs.id FROM prefs JOIN groups ON groups.id = prefs.groupID JOIN settings ON settings.id = prefs.settingID WHERE groups.name = :group AND settings.name = :name), (SELECT id FROM groups WHERE name = :group), (SELECT id FROM settings WHERE name = :name), :value, :now ) `); stmt.params.group = group; } else { stmt = this._stmt(` INSERT OR REPLACE INTO prefs (id, groupID, settingID, value, timestamp) VALUES( (SELECT prefs.id FROM prefs JOIN settings ON settings.id = prefs.settingID WHERE prefs.groupID IS NULL AND settings.name = :name), NULL, (SELECT id FROM settings WHERE name = :name), :value, :now ) `); } stmt.params.name = name; stmt.params.value = value; stmt.params.now = Date.now() / 1000; stmts.push(stmt); this._execStmts(stmts, { onDone: (reason, ok) => { if (ok) { this._cache.setWithCast(group, name, value); } cbHandleCompletion(callback, reason); if (ok) { this._notifyPrefSet( group, name, value, context && context.usePrivateBrowsing ); } }, onError: nsresult => { cbHandleError(callback, nsresult); }, }); }, removeByDomainAndName: function CPS2_removeByDomainAndName( group, name, context, callback ) { checkGroupArg(group); this._remove(group, name, false, context, callback); }, removeBySubdomainAndName: function CPS2_removeBySubdomainAndName( group, name, context, callback ) { checkGroupArg(group); this._remove(group, name, true, context, callback); }, removeGlobal: function CPS2_removeGlobal(name, context, callback) { this._remove(null, name, false, context, callback); }, _remove: function CPS2__remove( group, name, includeSubdomains, context, callback ) { group = this._parseGroup(group); checkNameArg(name); checkCallbackArg(callback, false); // Invalidate the cached values so consumers accessing the cache between now // and when the operation finishes don't get old data. for (let sgroup of this._cache.matchGroups(group, includeSubdomains)) { this._cache.remove(sgroup, name); } let stmts = []; // First get the matching prefs. stmts.push(this._commonGetStmt(group, name, includeSubdomains)); // Delete the matching prefs. let stmt = this._stmtWithGroupClause( group, includeSubdomains, ` DELETE FROM prefs WHERE settingID = (SELECT id FROM settings WHERE name = :name) AND CASE typeof(:group) WHEN 'null' THEN prefs.groupID IS NULL ELSE prefs.groupID IN (${GROUP_CLAUSE}) END ` ); stmt.params.name = name; stmts.push(stmt); stmts = stmts.concat(this._settingsAndGroupsCleanupStmts()); let prefs = new ContentPrefStore(); let isPrivate = context && context.usePrivateBrowsing; this._execStmts(stmts, { onRow: row => { let grp = row.getResultByName("grp"); prefs.set(grp, name, undefined); this._cache.set(grp, name, undefined); }, onDone: (reason, ok) => { if (ok) { this._cache.set(group, name, undefined); if (isPrivate) { for (let [sgroup] of this._pbStore.match( group, name, includeSubdomains )) { prefs.set(sgroup, name, undefined); this._pbStore.remove(sgroup, name); } } } cbHandleCompletion(callback, reason); if (ok) { for (let [sgroup, ,] of prefs) { this._notifyPrefRemoved(sgroup, name, isPrivate); } } }, onError: nsresult => { cbHandleError(callback, nsresult); }, }); }, // Deletes settings and groups that are no longer used. _settingsAndGroupsCleanupStmts() { // The NOTNULL term in the subquery of the second statment is needed because of // SQLite's weird IN behavior vis-a-vis NULLs. See http://sqlite.org/lang_expr.html. return [ this._stmt(` DELETE FROM settings WHERE id NOT IN (SELECT DISTINCT settingID FROM prefs) `), this._stmt(` DELETE FROM groups WHERE id NOT IN ( SELECT DISTINCT groupID FROM prefs WHERE groupID NOTNULL ) `), ]; }, removeByDomain: function CPS2_removeByDomain(group, context, callback) { checkGroupArg(group); this._removeByDomain(group, false, context, callback); }, removeBySubdomain: function CPS2_removeBySubdomain(group, context, callback) { checkGroupArg(group); this._removeByDomain(group, true, context, callback); }, removeAllGlobals: function CPS2_removeAllGlobals(context, callback) { this._removeByDomain(null, false, context, callback); }, _removeByDomain: function CPS2__removeByDomain( group, includeSubdomains, context, callback ) { group = this._parseGroup(group); checkCallbackArg(callback, false); // Invalidate the cached values so consumers accessing the cache between now // and when the operation finishes don't get old data. for (let sgroup of this._cache.matchGroups(group, includeSubdomains)) { this._cache.removeGroup(sgroup); } let stmts = []; // First get the matching prefs, then delete groups and prefs that reference // deleted groups. if (group) { stmts.push( this._stmtWithGroupClause( group, includeSubdomains, ` SELECT groups.name AS grp, settings.name AS name FROM prefs JOIN settings ON settings.id = prefs.settingID JOIN groups ON groups.id = prefs.groupID WHERE prefs.groupID IN (${GROUP_CLAUSE}) ` ) ); stmts.push( this._stmtWithGroupClause( group, includeSubdomains, `DELETE FROM groups WHERE id IN (${GROUP_CLAUSE})` ) ); stmts.push( this._stmt(` DELETE FROM prefs WHERE groupID NOTNULL AND groupID NOT IN (SELECT id FROM groups) `) ); } else { stmts.push( this._stmt(` SELECT NULL AS grp, settings.name AS name FROM prefs JOIN settings ON settings.id = prefs.settingID WHERE prefs.groupID IS NULL `) ); stmts.push(this._stmt("DELETE FROM prefs WHERE groupID IS NULL")); } // Finally delete settings that are no longer referenced. stmts.push( this._stmt(` DELETE FROM settings WHERE id NOT IN (SELECT DISTINCT settingID FROM prefs) `) ); let prefs = new ContentPrefStore(); let isPrivate = context && context.usePrivateBrowsing; this._execStmts(stmts, { onRow: row => { let grp = row.getResultByName("grp"); let name = row.getResultByName("name"); prefs.set(grp, name, undefined); this._cache.set(grp, name, undefined); }, onDone: (reason, ok) => { if (ok && isPrivate) { for (let [sgroup, sname] of this._pbStore) { if ( !group || (!includeSubdomains && group == sgroup) || (includeSubdomains && sgroup && this._pbStore.groupsMatchIncludingSubdomains(group, sgroup)) ) { prefs.set(sgroup, sname, undefined); this._pbStore.remove(sgroup, sname); } } } cbHandleCompletion(callback, reason); if (ok) { for (let [sgroup, sname] of prefs) { this._notifyPrefRemoved(sgroup, sname, isPrivate); } } }, onError: nsresult => { cbHandleError(callback, nsresult); }, }); }, _removeAllDomainsSince: function CPS2__removeAllDomainsSince( since, context, callback ) { checkCallbackArg(callback, false); since /= 1000; // Invalidate the cached values so consumers accessing the cache between now // and when the operation finishes don't get old data. // Invalidate all the group cache because we don't know which groups will be removed. this._cache.removeAllGroups(); let stmts = []; // Get prefs that are about to be removed to notify about their removal. let stmt = this._stmt(` SELECT groups.name AS grp, settings.name AS name FROM prefs JOIN settings ON settings.id = prefs.settingID JOIN groups ON groups.id = prefs.groupID WHERE timestamp >= :since `); stmt.params.since = since; stmts.push(stmt); // Do the actual remove. stmt = this._stmt(` DELETE FROM prefs WHERE groupID NOTNULL AND timestamp >= :since `); stmt.params.since = since; stmts.push(stmt); // Cleanup no longer used values. stmts = stmts.concat(this._settingsAndGroupsCleanupStmts()); let prefs = new ContentPrefStore(); let isPrivate = context && context.usePrivateBrowsing; this._execStmts(stmts, { onRow: row => { let grp = row.getResultByName("grp"); let name = row.getResultByName("name"); prefs.set(grp, name, undefined); this._cache.set(grp, name, undefined); }, onDone: (reason, ok) => { // This nukes all the groups in _pbStore since we don't have their timestamp // information. if (ok && isPrivate) { for (let [sgroup, sname] of this._pbStore) { if (sgroup) { prefs.set(sgroup, sname, undefined); } } this._pbStore.removeAllGroups(); } cbHandleCompletion(callback, reason); if (ok) { for (let [sgroup, sname] of prefs) { this._notifyPrefRemoved(sgroup, sname, isPrivate); } } }, onError: nsresult => { cbHandleError(callback, nsresult); }, }); }, removeAllDomainsSince: function CPS2_removeAllDomainsSince( since, context, callback ) { this._removeAllDomainsSince(since, context, callback); }, removeAllDomains: function CPS2_removeAllDomains(context, callback) { this._removeAllDomainsSince(0, context, callback); }, removeByName: function CPS2_removeByName(name, context, callback) { checkNameArg(name); checkCallbackArg(callback, false); // Invalidate the cached values so consumers accessing the cache between now // and when the operation finishes don't get old data. for (let [group, sname] of this._cache) { if (sname == name) { this._cache.remove(group, name); } } let stmts = []; // First get the matching prefs. Include null if any of those prefs are // global. let stmt = this._stmt(` SELECT groups.name AS grp FROM prefs JOIN settings ON settings.id = prefs.settingID JOIN groups ON groups.id = prefs.groupID WHERE settings.name = :name UNION SELECT NULL AS grp WHERE EXISTS ( SELECT prefs.id FROM prefs JOIN settings ON settings.id = prefs.settingID WHERE settings.name = :name AND prefs.groupID IS NULL ) `); stmt.params.name = name; stmts.push(stmt); // Delete the target settings. stmt = this._stmt("DELETE FROM settings WHERE name = :name"); stmt.params.name = name; stmts.push(stmt); // Delete prefs and groups that are no longer used. stmts.push( this._stmt( "DELETE FROM prefs WHERE settingID NOT IN (SELECT id FROM settings)" ) ); stmts.push( this._stmt(` DELETE FROM groups WHERE id NOT IN ( SELECT DISTINCT groupID FROM prefs WHERE groupID NOTNULL ) `) ); let prefs = new ContentPrefStore(); let isPrivate = context && context.usePrivateBrowsing; this._execStmts(stmts, { onRow: row => { let grp = row.getResultByName("grp"); prefs.set(grp, name, undefined); this._cache.set(grp, name, undefined); }, onDone: (reason, ok) => { if (ok && isPrivate) { for (let [sgroup, sname] of this._pbStore) { if (sname === name) { prefs.set(sgroup, name, undefined); this._pbStore.remove(sgroup, name); } } } cbHandleCompletion(callback, reason); if (ok) { for (let [sgroup, ,] of prefs) { this._notifyPrefRemoved(sgroup, name, isPrivate); } } }, onError: nsresult => { cbHandleError(callback, nsresult); }, }); }, /** * Returns the cached mozIStorageAsyncStatement for the given SQL. If no such * statement is cached, one is created and cached. * * @param sql The SQL query string. * @return The cached, possibly new, statement. */ _stmt: function CPS2__stmt(sql, cachable = true) { return { sql, cachable, params: {}, }; }, /** * Executes some async statements. * * @param stmts An array of mozIStorageAsyncStatements. * @param callbacks An object with the following methods: * onRow(row) (optional) * Called once for each result row. * row: A mozIStorageRow. * onDone(reason, reasonOK, didGetRow) (required) * Called when done. * reason: A nsIContentPrefService2.COMPLETE_* value. * reasonOK: reason == nsIContentPrefService2.COMPLETE_OK. * didGetRow: True if onRow was ever called. * onError(nsresult) (optional) * Called on error. * nsresult: The error code. */ _execStmts: async function CPS2__execStmts(stmts, callbacks) { let conn = await this.conn; let rows; let ok = true; try { rows = await executeStatementsInTransaction(conn, stmts); } catch (e) { ok = false; if (callbacks.onError) { try { callbacks.onError(e); } catch (e) { console.error(e); } } else { console.error(e); } } if (rows && callbacks.onRow) { for (let row of rows) { try { callbacks.onRow(row); } catch (e) { console.error(e); } } } try { callbacks.onDone( ok ? Ci.nsIContentPrefCallback2.COMPLETE_OK : Ci.nsIContentPrefCallback2.COMPLETE_ERROR, ok, rows && !!rows.length ); } catch (e) { console.error(e); } }, /** * Parses the domain (the "group", to use the database's term) from the given * string. * * @param groupStr Assumed to be either a string or falsey. * @return If groupStr is a valid URL string, returns the domain of * that URL. If groupStr is some other nonempty string, * returns groupStr itself. Otherwise returns null. * The return value is truncated at GROUP_NAME_MAX_LENGTH. */ _parseGroup: function CPS2__parseGroup(groupStr) { if (!groupStr) { return null; } try { var groupURI = Services.io.newURI(groupStr); groupStr = HostnameGrouper_group(groupURI); } catch (err) {} return groupStr.substring( 0, Ci.nsIContentPrefService2.GROUP_NAME_MAX_LENGTH - 1 ); }, _schedule: function CPS2__schedule(fn) { Services.tm.dispatchToMainThread(fn.bind(this)); }, // A hash of arrays of observers, indexed by setting name. _observers: new Map(), // An array of generic observers, which observe all settings. _genericObservers: new Set(), addObserverForName(aName, aObserver) { let observers; if (aName) { observers = this._observers.get(aName); if (!observers) { observers = new Set(); this._observers.set(aName, observers); } } else { observers = this._genericObservers; } observers.add(aObserver); }, removeObserverForName(aName, aObserver) { let observers; if (aName) { observers = this._observers.get(aName); if (!observers) { return; } } else { observers = this._genericObservers; } observers.delete(aObserver); }, /** * Construct a list of observers to notify about a change to some setting, * putting setting-specific observers before before generic ones, so observers * that initialize individual settings (like the page style controller) * execute before observers that display multiple settings and depend on them * being initialized first (like the content prefs sidebar). */ _getObservers(aName) { let genericObserverList = Array.from(this._genericObservers); if (aName) { let observersForName = this._observers.get(aName); if (observersForName) { return Array.from(observersForName).concat(genericObserverList); } } return genericObserverList; }, /** * Notify all observers about the removal of a preference. */ _notifyPrefRemoved: function ContentPrefService__notifyPrefRemoved( aGroup, aName, aIsPrivate ) { for (var observer of this._getObservers(aName)) { try { observer.onContentPrefRemoved(aGroup, aName, aIsPrivate); } catch (ex) { console.error(ex); } } }, /** * Notify all observers about a preference change. */ _notifyPrefSet: function ContentPrefService__notifyPrefSet( aGroup, aName, aValue, aIsPrivate ) { for (var observer of this._getObservers(aName)) { try { observer.onContentPrefSet(aGroup, aName, aValue, aIsPrivate); } catch (ex) { console.error(ex); } } }, extractDomain: function CPS2_extractDomain(str) { return this._parseGroup(str); }, /** * Tests use this as a backchannel by calling it directly. * * @param subj This value depends on topic. * @param topic The backchannel "method" name. */ observe: function CPS2_observe(subj, topic) { switch (topic) { case "profile-before-change": this._destroy(); break; case "last-pb-context-exited": this._pbStore.removeAll(); break; case "test:reset": let fn = subj.QueryInterface(Ci.xpcIJSWeakReference).get(); this._reset(fn); break; case "test:db": let obj = subj.QueryInterface(Ci.xpcIJSWeakReference).get(); obj.value = this.conn; break; } }, /** * Removes all state from the service. Used by tests. * * @param callback A function that will be called when done. */ async _reset(callback) { this._pbStore.removeAll(); this._cache.removeAll(); this._observers = new Map(); this._genericObservers = new Set(); let tables = ["prefs", "groups", "settings"]; let stmts = tables.map(t => this._stmt(`DELETE FROM ${t}`)); this._execStmts(stmts, { onDone: () => { callback(); }, }); }, QueryInterface: ChromeUtils.generateQI([ "nsIContentPrefService2", "nsIObserver", ]), // Database Creation & Access _dbVersion: 6, _dbSchema: { tables: { groups: "id INTEGER PRIMARY KEY, \ name TEXT NOT NULL", settings: "id INTEGER PRIMARY KEY, \ name TEXT NOT NULL", prefs: "id INTEGER PRIMARY KEY, \ groupID INTEGER REFERENCES groups(id), \ settingID INTEGER NOT NULL REFERENCES settings(id), \ value BLOB, \ timestamp INTEGER NOT NULL DEFAULT 0", // Storage in seconds, API in ms. 0 for migrated values. }, indices: { groups_idx: { table: "groups", columns: ["name"], }, settings_idx: { table: "settings", columns: ["name"], }, prefs_idx: { table: "prefs", columns: ["timestamp", "groupID", "settingID"], }, }, }, _debugLog: false, log: function CPS2_log(aMessage) { if (this._debugLog) { Services.console.logStringMessage("ContentPrefService2: " + aMessage); } }, async _getConnection(aAttemptNum = 0) { if ( Services.startup.isInOrBeyondShutdownPhase( Ci.nsIAppStartup.SHUTDOWN_PHASE_APPSHUTDOWN ) ) { throw new Error("Can't open content prefs, we're in shutdown."); } let path = PathUtils.join(PathUtils.profileDir, "content-prefs.sqlite"); let conn; let resetAndRetry = async e => { if (e.result != Cr.NS_ERROR_FILE_CORRUPTED) { throw e; } if (aAttemptNum >= this.MAX_ATTEMPTS) { if (conn) { await conn.close(); } this.log("Establishing connection failed too many times. Giving up."); throw e; } try { await this._failover(conn, path); } catch (e) { console.error(e); throw e; } return this._getConnection(++aAttemptNum); }; try { conn = await lazy.Sqlite.openConnection({ path, incrementalVacuum: true, vacuumOnIdle: true, }); try { lazy.Sqlite.shutdown.addBlocker( "Closing ContentPrefService2 connection.", () => conn.close() ); } catch (ex) { // Uh oh, we failed to add a shutdown blocker. Close the connection // anyway, but make sure that doesn't throw. try { await conn?.close(); } catch (ex) { console.error(ex); } return null; } } catch (e) { console.error(e); return resetAndRetry(e); } try { await this._dbMaybeInit(conn); } catch (e) { console.error(e); return resetAndRetry(e); } // Turn off disk synchronization checking to reduce disk churn and speed up // operations when prefs are changed rapidly (such as when a user repeatedly // changes the value of the browser zoom setting for a site). // // Note: this could cause database corruption if the OS crashes or machine // loses power before the data gets written to disk, but this is considered // a reasonable risk for the not-so-critical data stored in this database. await conn.execute("PRAGMA synchronous = OFF"); return conn; }, async _failover(aConn, aPath) { this.log("Cleaning up DB file - close & remove & backup."); if (aConn) { await aConn.close(); } let uniquePath = await IOUtils.createUniqueFile( PathUtils.parent(aPath), PathUtils.filename(aPath) + ".corrupt", 0o600 ); await IOUtils.copy(aPath, uniquePath); await IOUtils.remove(aPath); this.log("Completed DB cleanup."); }, _dbMaybeInit: async function CPS2__dbMaybeInit(aConn) { let version = parseInt(await aConn.getSchemaVersion(), 10); this.log("Schema version: " + version); if (version == 0) { await this._dbCreateSchema(aConn); } else if (version != this._dbVersion) { await this._dbMigrate(aConn, version, this._dbVersion); } }, _createTable: async function CPS2__createTable(aConn, aName) { let tSQL = this._dbSchema.tables[aName]; this.log("Creating table " + aName + " with " + tSQL); await aConn.execute(`CREATE TABLE ${aName} (${tSQL})`); }, _createIndex: async function CPS2__createTable(aConn, aName) { let index = this._dbSchema.indices[aName]; let statement = "CREATE INDEX IF NOT EXISTS " + aName + " ON " + index.table + "(" + index.columns.join(", ") + ")"; await aConn.execute(statement); }, _dbCreateSchema: async function CPS2__dbCreateSchema(aConn) { await aConn.executeTransaction(async () => { this.log("Creating DB -- tables"); for (let name in this._dbSchema.tables) { await this._createTable(aConn, name); } this.log("Creating DB -- indices"); for (let name in this._dbSchema.indices) { await this._createIndex(aConn, name); } await aConn.setSchemaVersion(this._dbVersion); }); }, _dbMigrate: async function CPS2__dbMigrate(aConn, aOldVersion, aNewVersion) { /** * Migrations should follow the template rules in bug 1074817 comment 3 which are: * 1. Migration should be incremental and non-breaking. * 2. It should be idempotent because one can downgrade an upgrade again. * On downgrade: * 1. Decrement schema version so that upgrade runs the migrations again. */ await aConn.executeTransaction(async () => { for (let i = aOldVersion; i < aNewVersion; i++) { let migrationName = "_dbMigrate" + i + "To" + (i + 1); if (typeof this[migrationName] != "function") { throw new Error( "no migrator function from version " + aOldVersion + " to version " + aNewVersion ); } await this[migrationName](aConn); } await aConn.setSchemaVersion(aNewVersion); }); }, _dbMigrate1To2: async function CPS2___dbMigrate1To2(aConn) { await aConn.execute("ALTER TABLE groups RENAME TO groupsOld"); await this._createTable(aConn, "groups"); await aConn.execute(` INSERT INTO groups (id, name) SELECT id, name FROM groupsOld `); await aConn.execute("DROP TABLE groupers"); await aConn.execute("DROP TABLE groupsOld"); }, _dbMigrate2To3: async function CPS2__dbMigrate2To3(aConn) { for (let name in this._dbSchema.indices) { await this._createIndex(aConn, name); } }, _dbMigrate3To4: async function CPS2__dbMigrate3To4(aConn) { // Add timestamp column if it does not exist yet. This operation is idempotent. try { await aConn.execute("SELECT timestamp FROM prefs"); } catch (e) { await aConn.execute( "ALTER TABLE prefs ADD COLUMN timestamp INTEGER NOT NULL DEFAULT 0" ); } // To modify prefs_idx drop it and create again. await aConn.execute("DROP INDEX IF EXISTS prefs_idx"); for (let name in this._dbSchema.indices) { await this._createIndex(aConn, name); } }, async _dbMigrate4To5(conn) { // This is a data migration for browser.download.lastDir. While it may not // affect all consumers, it's simpler and safer to do it here than elsewhere. await conn.execute(` DELETE FROM prefs WHERE id IN ( SELECT p.id FROM prefs p JOIN groups g ON g.id = p.groupID JOIN settings s ON s.id = p.settingID WHERE s.name = 'browser.download.lastDir' AND ( (g.name BETWEEN 'data:' AND 'data:' || X'FFFF') OR (g.name BETWEEN 'file:' AND 'file:' || X'FFFF') ) ) `); await conn.execute(` DELETE FROM groups WHERE NOT EXISTS ( SELECT 1 FROM prefs WHERE groupId = groups.id ) `); // Trim group names longer than MAX_GROUP_LENGTH. await conn.execute( ` UPDATE groups SET name = substr(name, 0, :maxlen) WHERE LENGTH(name) > :maxlen `, { maxlen: Ci.nsIContentPrefService2.GROUP_NAME_MAX_LENGTH, } ); }, async _dbMigrate5To6(conn) { // This is a data migration for blob: URIs, as we started storing their // origin when possible. // Rather than trying to migrate blob URIs to their origins, that would // require multiple steps for a tiny benefit (they never worked anyway), // just remove them, as they are one-time generated URLs unlikely to be // useful in the future. New inserted blobs will do the right thing. await conn.execute(` DELETE FROM prefs WHERE id IN ( SELECT p.id FROM prefs p JOIN groups g ON g.id = p.groupID AND g.name BETWEEN 'blob:' AND 'blob:' || X'FFFF' ) `); await conn.execute(` DELETE FROM groups WHERE NOT EXISTS ( SELECT 1 FROM prefs WHERE groupId = groups.id ) `); }, }; function checkGroupArg(group) { if (!group || typeof group != "string") { throw invalidArg("domain must be nonempty string."); } } function checkNameArg(name) { if (!name || typeof name != "string") { throw invalidArg("name must be nonempty string."); } } function checkValueArg(value) { if (value === undefined) { throw invalidArg("value must not be undefined."); } } function checkCallbackArg(callback, required) { if (callback && !(callback instanceof Ci.nsIContentPrefCallback2)) { throw invalidArg("callback must be an nsIContentPrefCallback2."); } if (!callback && required) { throw invalidArg("callback must be given."); } } function invalidArg(msg) { return Components.Exception(msg, Cr.NS_ERROR_INVALID_ARG); } // XPCOM Plumbing