diff options
Diffstat (limited to 'toolkit/components/contentprefs/ContentPrefService2.sys.mjs')
-rw-r--r-- | toolkit/components/contentprefs/ContentPrefService2.sys.mjs | 1427 |
1 files changed, 1427 insertions, 0 deletions
diff --git a/toolkit/components/contentprefs/ContentPrefService2.sys.mjs b/toolkit/components/contentprefs/ContentPrefService2.sys.mjs new file mode 100644 index 0000000000..360689b4e8 --- /dev/null +++ b/toolkit/components/contentprefs/ContentPrefService2.sys.mjs @@ -0,0 +1,1427 @@ +/* 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(group, name, val) { + 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; + }); +} + +function HostnameGrouper_group(aURI) { + var group; + + try { + // 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). + + group = aURI.host; + if (!group) { + throw new Error("can't derive group from host; no host in URI"); + } + } 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 { + var url = aURI.QueryInterface(Ci.nsIURL); + group = aURI.prePath + url.filePath; + } catch (ex) { + group = aURI.spec; + } + } + + return group; +} + +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, gotRow) => { + 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. + * @param data This value depends on topic. + */ + observe: function CPS2_observe(subj, topic, data) { + 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: 5, + + _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, + } + ); + }, +}; + +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 |