Bug 1852754: part 9) Add tests for dynamically loading <link rel="prefetch"> elements...
[gecko.git] / toolkit / components / contentprefs / ContentPrefService2.sys.mjs
blob360689b4e8801f5cfcdc9b26a53312019ca40861
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import {
6   ContentPref,
7   cbHandleCompletion,
8   cbHandleError,
9   cbHandleResult,
10 } from "resource://gre/modules/ContentPrefUtils.sys.mjs";
12 import { ContentPrefStore } from "resource://gre/modules/ContentPrefStore.sys.mjs";
14 const lazy = {};
15 ChromeUtils.defineESModuleGetters(lazy, {
16   Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
17 });
19 const CACHE_MAX_GROUP_ENTRIES = 100;
21 const GROUP_CLAUSE = `
22   SELECT id
23   FROM groups
24   WHERE name = :group OR
25         (:includeSubdomains AND name LIKE :pattern ESCAPE '/')
28 export function ContentPrefService2() {
29   if (Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_CONTENT) {
30     return ChromeUtils.importESModule(
31       "resource://gre/modules/ContentPrefServiceChild.sys.mjs"
32     ).ContentPrefServiceChild;
33   }
35   Services.obs.addObserver(this, "last-pb-context-exited");
37   // Observe shutdown so we can shut down the database connection.
38   Services.obs.addObserver(this, "profile-before-change");
41 const cache = new ContentPrefStore();
42 cache.set = function CPS_cache_set(group, name, val) {
43   Object.getPrototypeOf(this).set.apply(this, arguments);
44   let groupCount = this._groups.size;
45   if (groupCount >= CACHE_MAX_GROUP_ENTRIES) {
46     // Clean half of the entries
47     for (let [group, name] of this) {
48       this.remove(group, name);
49       groupCount--;
50       if (groupCount < CACHE_MAX_GROUP_ENTRIES / 2) {
51         break;
52       }
53     }
54   }
57 const privModeStorage = new ContentPrefStore();
59 function executeStatementsInTransaction(conn, stmts) {
60   return conn.executeTransaction(async () => {
61     let rows = [];
62     for (let { sql, params, cachable } of stmts) {
63       let execute = cachable ? conn.executeCached : conn.execute;
64       let stmtRows = await execute.call(conn, sql, params);
65       rows = rows.concat(stmtRows);
66     }
67     return rows;
68   });
71 function HostnameGrouper_group(aURI) {
72   var group;
74   try {
75     // Accessing the host property of the URI will throw an exception
76     // if the URI is of a type that doesn't have a host property.
77     // Otherwise, we manually throw an exception if the host is empty,
78     // since the effect is the same (we can't derive a group from it).
80     group = aURI.host;
81     if (!group) {
82       throw new Error("can't derive group from host; no host in URI");
83     }
84   } catch (ex) {
85     // If we don't have a host, then use the entire URI (minus the query,
86     // reference, and hash, if possible) as the group.  This means that URIs
87     // like about:mozilla and about:blank will be considered separate groups,
88     // but at least they'll be grouped somehow.
90     // This also means that each individual file: URL will be considered
91     // its own group.  This seems suboptimal, but so does treating the entire
92     // file: URL space as a single group (especially if folks start setting
93     // group-specific capabilities prefs).
95     // XXX Is there something better we can do here?
97     try {
98       var url = aURI.QueryInterface(Ci.nsIURL);
99       group = aURI.prePath + url.filePath;
100     } catch (ex) {
101       group = aURI.spec;
102     }
103   }
105   return group;
108 ContentPrefService2.prototype = {
109   // XPCOM Plumbing
111   classID: Components.ID("{e3f772f3-023f-4b32-b074-36cf0fd5d414}"),
113   // Destruction
115   _destroy() {
116     Services.obs.removeObserver(this, "profile-before-change");
117     Services.obs.removeObserver(this, "last-pb-context-exited");
119     // Delete references to XPCOM components to make sure we don't leak them
120     // (although we haven't observed leakage in tests).  Also delete references
121     // in _observers and _genericObservers to avoid cycles with those that
122     // refer to us and don't remove themselves from those observer pools.
123     delete this._observers;
124     delete this._genericObservers;
125   },
127   // in-memory cache and private-browsing stores
129   _cache: cache,
130   _pbStore: privModeStorage,
132   _connPromise: null,
134   get conn() {
135     if (this._connPromise) {
136       return this._connPromise;
137     }
139     return (this._connPromise = (async () => {
140       let conn;
141       try {
142         conn = await this._getConnection();
143       } catch (e) {
144         this.log("Failed to establish database connection: " + e);
145         throw e;
146       }
147       return conn;
148     })());
149   },
151   // nsIContentPrefService
153   getByName: function CPS2_getByName(name, context, callback) {
154     checkNameArg(name);
155     checkCallbackArg(callback, true);
157     // Some prefs may be in both the database and the private browsing store.
158     // Notify the caller of such prefs only once, using the values from private
159     // browsing.
160     let pbPrefs = new ContentPrefStore();
161     if (context && context.usePrivateBrowsing) {
162       for (let [sgroup, sname, val] of this._pbStore) {
163         if (sname == name) {
164           pbPrefs.set(sgroup, sname, val);
165         }
166       }
167     }
169     let stmt1 = this._stmt(`
170       SELECT groups.name AS grp, prefs.value AS value
171       FROM prefs
172       JOIN settings ON settings.id = prefs.settingID
173       JOIN groups ON groups.id = prefs.groupID
174       WHERE settings.name = :name
175     `);
176     stmt1.params.name = name;
178     let stmt2 = this._stmt(`
179       SELECT NULL AS grp, prefs.value AS value
180       FROM prefs
181       JOIN settings ON settings.id = prefs.settingID
182       WHERE settings.name = :name AND prefs.groupID ISNULL
183     `);
184     stmt2.params.name = name;
186     this._execStmts([stmt1, stmt2], {
187       onRow: row => {
188         let grp = row.getResultByName("grp");
189         let val = row.getResultByName("value");
190         this._cache.set(grp, name, val);
191         if (!pbPrefs.has(grp, name)) {
192           cbHandleResult(callback, new ContentPref(grp, name, val));
193         }
194       },
195       onDone: (reason, ok, gotRow) => {
196         if (ok) {
197           for (let [pbGroup, pbName, pbVal] of pbPrefs) {
198             cbHandleResult(callback, new ContentPref(pbGroup, pbName, pbVal));
199           }
200         }
201         cbHandleCompletion(callback, reason);
202       },
203       onError: nsresult => {
204         cbHandleError(callback, nsresult);
205       },
206     });
207   },
209   getByDomainAndName: function CPS2_getByDomainAndName(
210     group,
211     name,
212     context,
213     callback
214   ) {
215     checkGroupArg(group);
216     this._get(group, name, false, context, callback);
217   },
219   getBySubdomainAndName: function CPS2_getBySubdomainAndName(
220     group,
221     name,
222     context,
223     callback
224   ) {
225     checkGroupArg(group);
226     this._get(group, name, true, context, callback);
227   },
229   getGlobal: function CPS2_getGlobal(name, context, callback) {
230     this._get(null, name, false, context, callback);
231   },
233   _get: function CPS2__get(group, name, includeSubdomains, context, callback) {
234     group = this._parseGroup(group);
235     checkNameArg(name);
236     checkCallbackArg(callback, true);
238     // Some prefs may be in both the database and the private browsing store.
239     // Notify the caller of such prefs only once, using the values from private
240     // browsing.
241     let pbPrefs = new ContentPrefStore();
242     if (context && context.usePrivateBrowsing) {
243       for (let [sgroup, val] of this._pbStore.match(
244         group,
245         name,
246         includeSubdomains
247       )) {
248         pbPrefs.set(sgroup, name, val);
249       }
250     }
252     this._execStmts([this._commonGetStmt(group, name, includeSubdomains)], {
253       onRow: row => {
254         let grp = row.getResultByName("grp");
255         let val = row.getResultByName("value");
256         this._cache.set(grp, name, val);
257         if (!pbPrefs.has(group, name)) {
258           cbHandleResult(callback, new ContentPref(grp, name, val));
259         }
260       },
261       onDone: (reason, ok, gotRow) => {
262         if (ok) {
263           if (!gotRow) {
264             this._cache.set(group, name, undefined);
265           }
266           for (let [pbGroup, pbName, pbVal] of pbPrefs) {
267             cbHandleResult(callback, new ContentPref(pbGroup, pbName, pbVal));
268           }
269         }
270         cbHandleCompletion(callback, reason);
271       },
272       onError: nsresult => {
273         cbHandleError(callback, nsresult);
274       },
275     });
276   },
278   _commonGetStmt: function CPS2__commonGetStmt(group, name, includeSubdomains) {
279     let stmt = group
280       ? this._stmtWithGroupClause(
281           group,
282           includeSubdomains,
283           `
284         SELECT groups.name AS grp, prefs.value AS value
285         FROM prefs
286         JOIN settings ON settings.id = prefs.settingID
287         JOIN groups ON groups.id = prefs.groupID
288         WHERE settings.name = :name AND prefs.groupID IN (${GROUP_CLAUSE})
289       `
290         )
291       : this._stmt(`
292         SELECT NULL AS grp, prefs.value AS value
293         FROM prefs
294         JOIN settings ON settings.id = prefs.settingID
295         WHERE settings.name = :name AND prefs.groupID ISNULL
296       `);
297     stmt.params.name = name;
298     return stmt;
299   },
301   _stmtWithGroupClause: function CPS2__stmtWithGroupClause(
302     group,
303     includeSubdomains,
304     sql
305   ) {
306     let stmt = this._stmt(sql, false);
307     stmt.params.group = group;
308     stmt.params.includeSubdomains = includeSubdomains || false;
309     stmt.params.pattern =
310       "%." + (group == null ? null : group.replace(/\/|%|_/g, "/$&"));
311     return stmt;
312   },
314   getCachedByDomainAndName: function CPS2_getCachedByDomainAndName(
315     group,
316     name,
317     context
318   ) {
319     checkGroupArg(group);
320     let prefs = this._getCached(group, name, false, context);
321     return prefs[0] || null;
322   },
324   getCachedBySubdomainAndName: function CPS2_getCachedBySubdomainAndName(
325     group,
326     name,
327     context
328   ) {
329     checkGroupArg(group);
330     return this._getCached(group, name, true, context);
331   },
333   getCachedGlobal: function CPS2_getCachedGlobal(name, context) {
334     let prefs = this._getCached(null, name, false, context);
335     return prefs[0] || null;
336   },
338   _getCached: function CPS2__getCached(
339     group,
340     name,
341     includeSubdomains,
342     context
343   ) {
344     group = this._parseGroup(group);
345     checkNameArg(name);
347     let storesToCheck = [this._cache];
348     if (context && context.usePrivateBrowsing) {
349       storesToCheck.push(this._pbStore);
350     }
352     let outStore = new ContentPrefStore();
353     storesToCheck.forEach(function (store) {
354       for (let [sgroup, val] of store.match(group, name, includeSubdomains)) {
355         outStore.set(sgroup, name, val);
356       }
357     });
359     let prefs = [];
360     for (let [sgroup, sname, val] of outStore) {
361       prefs.push(new ContentPref(sgroup, sname, val));
362     }
363     return prefs;
364   },
366   set: function CPS2_set(group, name, value, context, callback) {
367     checkGroupArg(group);
368     this._set(group, name, value, context, callback);
369   },
371   setGlobal: function CPS2_setGlobal(name, value, context, callback) {
372     this._set(null, name, value, context, callback);
373   },
375   _set: function CPS2__set(group, name, value, context, callback) {
376     group = this._parseGroup(group);
377     checkNameArg(name);
378     checkValueArg(value);
379     checkCallbackArg(callback, false);
381     if (context && context.usePrivateBrowsing) {
382       this._pbStore.set(group, name, value);
383       this._schedule(function () {
384         cbHandleCompletion(callback, Ci.nsIContentPrefCallback2.COMPLETE_OK);
385         this._notifyPrefSet(group, name, value, context.usePrivateBrowsing);
386       });
387       return;
388     }
390     // Invalidate the cached value so consumers accessing the cache between now
391     // and when the operation finishes don't get old data.
392     this._cache.remove(group, name);
394     let stmts = [];
396     // Create the setting if it doesn't exist.
397     let stmt = this._stmt(`
398       INSERT OR IGNORE INTO settings (id, name)
399       VALUES((SELECT id FROM settings WHERE name = :name), :name)
400     `);
401     stmt.params.name = name;
402     stmts.push(stmt);
404     // Create the group if it doesn't exist.
405     if (group) {
406       stmt = this._stmt(`
407         INSERT OR IGNORE INTO groups (id, name)
408         VALUES((SELECT id FROM groups WHERE name = :group), :group)
409       `);
410       stmt.params.group = group;
411       stmts.push(stmt);
412     }
414     // Finally create or update the pref.
415     if (group) {
416       stmt = this._stmt(`
417         INSERT OR REPLACE INTO prefs (id, groupID, settingID, value, timestamp)
418         VALUES(
419           (SELECT prefs.id
420            FROM prefs
421            JOIN groups ON groups.id = prefs.groupID
422            JOIN settings ON settings.id = prefs.settingID
423            WHERE groups.name = :group AND settings.name = :name),
424           (SELECT id FROM groups WHERE name = :group),
425           (SELECT id FROM settings WHERE name = :name),
426           :value,
427           :now
428         )
429       `);
430       stmt.params.group = group;
431     } else {
432       stmt = this._stmt(`
433         INSERT OR REPLACE INTO prefs (id, groupID, settingID, value, timestamp)
434         VALUES(
435           (SELECT prefs.id
436            FROM prefs
437            JOIN settings ON settings.id = prefs.settingID
438            WHERE prefs.groupID IS NULL AND settings.name = :name),
439           NULL,
440           (SELECT id FROM settings WHERE name = :name),
441           :value,
442           :now
443         )
444       `);
445     }
446     stmt.params.name = name;
447     stmt.params.value = value;
448     stmt.params.now = Date.now() / 1000;
449     stmts.push(stmt);
451     this._execStmts(stmts, {
452       onDone: (reason, ok) => {
453         if (ok) {
454           this._cache.setWithCast(group, name, value);
455         }
456         cbHandleCompletion(callback, reason);
457         if (ok) {
458           this._notifyPrefSet(
459             group,
460             name,
461             value,
462             context && context.usePrivateBrowsing
463           );
464         }
465       },
466       onError: nsresult => {
467         cbHandleError(callback, nsresult);
468       },
469     });
470   },
472   removeByDomainAndName: function CPS2_removeByDomainAndName(
473     group,
474     name,
475     context,
476     callback
477   ) {
478     checkGroupArg(group);
479     this._remove(group, name, false, context, callback);
480   },
482   removeBySubdomainAndName: function CPS2_removeBySubdomainAndName(
483     group,
484     name,
485     context,
486     callback
487   ) {
488     checkGroupArg(group);
489     this._remove(group, name, true, context, callback);
490   },
492   removeGlobal: function CPS2_removeGlobal(name, context, callback) {
493     this._remove(null, name, false, context, callback);
494   },
496   _remove: function CPS2__remove(
497     group,
498     name,
499     includeSubdomains,
500     context,
501     callback
502   ) {
503     group = this._parseGroup(group);
504     checkNameArg(name);
505     checkCallbackArg(callback, false);
507     // Invalidate the cached values so consumers accessing the cache between now
508     // and when the operation finishes don't get old data.
509     for (let sgroup of this._cache.matchGroups(group, includeSubdomains)) {
510       this._cache.remove(sgroup, name);
511     }
513     let stmts = [];
515     // First get the matching prefs.
516     stmts.push(this._commonGetStmt(group, name, includeSubdomains));
518     // Delete the matching prefs.
519     let stmt = this._stmtWithGroupClause(
520       group,
521       includeSubdomains,
522       `
523       DELETE FROM prefs
524       WHERE settingID = (SELECT id FROM settings WHERE name = :name) AND
525             CASE typeof(:group)
526             WHEN 'null' THEN prefs.groupID IS NULL
527             ELSE prefs.groupID IN (${GROUP_CLAUSE})
528             END
529     `
530     );
531     stmt.params.name = name;
532     stmts.push(stmt);
534     stmts = stmts.concat(this._settingsAndGroupsCleanupStmts());
536     let prefs = new ContentPrefStore();
538     let isPrivate = context && context.usePrivateBrowsing;
539     this._execStmts(stmts, {
540       onRow: row => {
541         let grp = row.getResultByName("grp");
542         prefs.set(grp, name, undefined);
543         this._cache.set(grp, name, undefined);
544       },
545       onDone: (reason, ok) => {
546         if (ok) {
547           this._cache.set(group, name, undefined);
548           if (isPrivate) {
549             for (let [sgroup] of this._pbStore.match(
550               group,
551               name,
552               includeSubdomains
553             )) {
554               prefs.set(sgroup, name, undefined);
555               this._pbStore.remove(sgroup, name);
556             }
557           }
558         }
559         cbHandleCompletion(callback, reason);
560         if (ok) {
561           for (let [sgroup, ,] of prefs) {
562             this._notifyPrefRemoved(sgroup, name, isPrivate);
563           }
564         }
565       },
566       onError: nsresult => {
567         cbHandleError(callback, nsresult);
568       },
569     });
570   },
572   // Deletes settings and groups that are no longer used.
573   _settingsAndGroupsCleanupStmts() {
574     // The NOTNULL term in the subquery of the second statment is needed because of
575     // SQLite's weird IN behavior vis-a-vis NULLs.  See http://sqlite.org/lang_expr.html.
576     return [
577       this._stmt(`
578         DELETE FROM settings
579         WHERE id NOT IN (SELECT DISTINCT settingID FROM prefs)
580       `),
581       this._stmt(`
582         DELETE FROM groups WHERE id NOT IN (
583           SELECT DISTINCT groupID FROM prefs WHERE groupID NOTNULL
584         )
585       `),
586     ];
587   },
589   removeByDomain: function CPS2_removeByDomain(group, context, callback) {
590     checkGroupArg(group);
591     this._removeByDomain(group, false, context, callback);
592   },
594   removeBySubdomain: function CPS2_removeBySubdomain(group, context, callback) {
595     checkGroupArg(group);
596     this._removeByDomain(group, true, context, callback);
597   },
599   removeAllGlobals: function CPS2_removeAllGlobals(context, callback) {
600     this._removeByDomain(null, false, context, callback);
601   },
603   _removeByDomain: function CPS2__removeByDomain(
604     group,
605     includeSubdomains,
606     context,
607     callback
608   ) {
609     group = this._parseGroup(group);
610     checkCallbackArg(callback, false);
612     // Invalidate the cached values so consumers accessing the cache between now
613     // and when the operation finishes don't get old data.
614     for (let sgroup of this._cache.matchGroups(group, includeSubdomains)) {
615       this._cache.removeGroup(sgroup);
616     }
618     let stmts = [];
620     // First get the matching prefs, then delete groups and prefs that reference
621     // deleted groups.
622     if (group) {
623       stmts.push(
624         this._stmtWithGroupClause(
625           group,
626           includeSubdomains,
627           `
628         SELECT groups.name AS grp, settings.name AS name
629         FROM prefs
630         JOIN settings ON settings.id = prefs.settingID
631         JOIN groups ON groups.id = prefs.groupID
632         WHERE prefs.groupID IN (${GROUP_CLAUSE})
633       `
634         )
635       );
636       stmts.push(
637         this._stmtWithGroupClause(
638           group,
639           includeSubdomains,
640           `DELETE FROM groups WHERE id IN (${GROUP_CLAUSE})`
641         )
642       );
643       stmts.push(
644         this._stmt(`
645         DELETE FROM prefs
646         WHERE groupID NOTNULL AND groupID NOT IN (SELECT id FROM groups)
647       `)
648       );
649     } else {
650       stmts.push(
651         this._stmt(`
652         SELECT NULL AS grp, settings.name AS name
653         FROM prefs
654         JOIN settings ON settings.id = prefs.settingID
655         WHERE prefs.groupID IS NULL
656       `)
657       );
658       stmts.push(this._stmt("DELETE FROM prefs WHERE groupID IS NULL"));
659     }
661     // Finally delete settings that are no longer referenced.
662     stmts.push(
663       this._stmt(`
664       DELETE FROM settings
665       WHERE id NOT IN (SELECT DISTINCT settingID FROM prefs)
666     `)
667     );
669     let prefs = new ContentPrefStore();
671     let isPrivate = context && context.usePrivateBrowsing;
672     this._execStmts(stmts, {
673       onRow: row => {
674         let grp = row.getResultByName("grp");
675         let name = row.getResultByName("name");
676         prefs.set(grp, name, undefined);
677         this._cache.set(grp, name, undefined);
678       },
679       onDone: (reason, ok) => {
680         if (ok && isPrivate) {
681           for (let [sgroup, sname] of this._pbStore) {
682             if (
683               !group ||
684               (!includeSubdomains && group == sgroup) ||
685               (includeSubdomains &&
686                 sgroup &&
687                 this._pbStore.groupsMatchIncludingSubdomains(group, sgroup))
688             ) {
689               prefs.set(sgroup, sname, undefined);
690               this._pbStore.remove(sgroup, sname);
691             }
692           }
693         }
694         cbHandleCompletion(callback, reason);
695         if (ok) {
696           for (let [sgroup, sname] of prefs) {
697             this._notifyPrefRemoved(sgroup, sname, isPrivate);
698           }
699         }
700       },
701       onError: nsresult => {
702         cbHandleError(callback, nsresult);
703       },
704     });
705   },
707   _removeAllDomainsSince: function CPS2__removeAllDomainsSince(
708     since,
709     context,
710     callback
711   ) {
712     checkCallbackArg(callback, false);
714     since /= 1000;
716     // Invalidate the cached values so consumers accessing the cache between now
717     // and when the operation finishes don't get old data.
718     // Invalidate all the group cache because we don't know which groups will be removed.
719     this._cache.removeAllGroups();
721     let stmts = [];
723     // Get prefs that are about to be removed to notify about their removal.
724     let stmt = this._stmt(`
725       SELECT groups.name AS grp, settings.name AS name
726       FROM prefs
727       JOIN settings ON settings.id = prefs.settingID
728       JOIN groups ON groups.id = prefs.groupID
729       WHERE timestamp >= :since
730     `);
731     stmt.params.since = since;
732     stmts.push(stmt);
734     // Do the actual remove.
735     stmt = this._stmt(`
736       DELETE FROM prefs WHERE groupID NOTNULL AND timestamp >= :since
737     `);
738     stmt.params.since = since;
739     stmts.push(stmt);
741     // Cleanup no longer used values.
742     stmts = stmts.concat(this._settingsAndGroupsCleanupStmts());
744     let prefs = new ContentPrefStore();
745     let isPrivate = context && context.usePrivateBrowsing;
746     this._execStmts(stmts, {
747       onRow: row => {
748         let grp = row.getResultByName("grp");
749         let name = row.getResultByName("name");
750         prefs.set(grp, name, undefined);
751         this._cache.set(grp, name, undefined);
752       },
753       onDone: (reason, ok) => {
754         // This nukes all the groups in _pbStore since we don't have their timestamp
755         // information.
756         if (ok && isPrivate) {
757           for (let [sgroup, sname] of this._pbStore) {
758             if (sgroup) {
759               prefs.set(sgroup, sname, undefined);
760             }
761           }
762           this._pbStore.removeAllGroups();
763         }
764         cbHandleCompletion(callback, reason);
765         if (ok) {
766           for (let [sgroup, sname] of prefs) {
767             this._notifyPrefRemoved(sgroup, sname, isPrivate);
768           }
769         }
770       },
771       onError: nsresult => {
772         cbHandleError(callback, nsresult);
773       },
774     });
775   },
777   removeAllDomainsSince: function CPS2_removeAllDomainsSince(
778     since,
779     context,
780     callback
781   ) {
782     this._removeAllDomainsSince(since, context, callback);
783   },
785   removeAllDomains: function CPS2_removeAllDomains(context, callback) {
786     this._removeAllDomainsSince(0, context, callback);
787   },
789   removeByName: function CPS2_removeByName(name, context, callback) {
790     checkNameArg(name);
791     checkCallbackArg(callback, false);
793     // Invalidate the cached values so consumers accessing the cache between now
794     // and when the operation finishes don't get old data.
795     for (let [group, sname] of this._cache) {
796       if (sname == name) {
797         this._cache.remove(group, name);
798       }
799     }
801     let stmts = [];
803     // First get the matching prefs.  Include null if any of those prefs are
804     // global.
805     let stmt = this._stmt(`
806       SELECT groups.name AS grp
807       FROM prefs
808       JOIN settings ON settings.id = prefs.settingID
809       JOIN groups ON groups.id = prefs.groupID
810       WHERE settings.name = :name
811       UNION
812       SELECT NULL AS grp
813       WHERE EXISTS (
814         SELECT prefs.id
815         FROM prefs
816         JOIN settings ON settings.id = prefs.settingID
817         WHERE settings.name = :name AND prefs.groupID IS NULL
818       )
819     `);
820     stmt.params.name = name;
821     stmts.push(stmt);
823     // Delete the target settings.
824     stmt = this._stmt("DELETE FROM settings WHERE name = :name");
825     stmt.params.name = name;
826     stmts.push(stmt);
828     // Delete prefs and groups that are no longer used.
829     stmts.push(
830       this._stmt(
831         "DELETE FROM prefs WHERE settingID NOT IN (SELECT id FROM settings)"
832       )
833     );
834     stmts.push(
835       this._stmt(`
836       DELETE FROM groups WHERE id NOT IN (
837         SELECT DISTINCT groupID FROM prefs WHERE groupID NOTNULL
838       )
839     `)
840     );
842     let prefs = new ContentPrefStore();
843     let isPrivate = context && context.usePrivateBrowsing;
845     this._execStmts(stmts, {
846       onRow: row => {
847         let grp = row.getResultByName("grp");
848         prefs.set(grp, name, undefined);
849         this._cache.set(grp, name, undefined);
850       },
851       onDone: (reason, ok) => {
852         if (ok && isPrivate) {
853           for (let [sgroup, sname] of this._pbStore) {
854             if (sname === name) {
855               prefs.set(sgroup, name, undefined);
856               this._pbStore.remove(sgroup, name);
857             }
858           }
859         }
860         cbHandleCompletion(callback, reason);
861         if (ok) {
862           for (let [sgroup, ,] of prefs) {
863             this._notifyPrefRemoved(sgroup, name, isPrivate);
864           }
865         }
866       },
867       onError: nsresult => {
868         cbHandleError(callback, nsresult);
869       },
870     });
871   },
873   /**
874    * Returns the cached mozIStorageAsyncStatement for the given SQL.  If no such
875    * statement is cached, one is created and cached.
876    *
877    * @param sql  The SQL query string.
878    * @return     The cached, possibly new, statement.
879    */
880   _stmt: function CPS2__stmt(sql, cachable = true) {
881     return {
882       sql,
883       cachable,
884       params: {},
885     };
886   },
888   /**
889    * Executes some async statements.
890    *
891    * @param stmts      An array of mozIStorageAsyncStatements.
892    * @param callbacks  An object with the following methods:
893    *                   onRow(row) (optional)
894    *                     Called once for each result row.
895    *                     row: A mozIStorageRow.
896    *                   onDone(reason, reasonOK, didGetRow) (required)
897    *                     Called when done.
898    *                     reason: A nsIContentPrefService2.COMPLETE_* value.
899    *                     reasonOK: reason == nsIContentPrefService2.COMPLETE_OK.
900    *                     didGetRow: True if onRow was ever called.
901    *                   onError(nsresult) (optional)
902    *                     Called on error.
903    *                     nsresult: The error code.
904    */
905   _execStmts: async function CPS2__execStmts(stmts, callbacks) {
906     let conn = await this.conn;
907     let rows;
908     let ok = true;
909     try {
910       rows = await executeStatementsInTransaction(conn, stmts);
911     } catch (e) {
912       ok = false;
913       if (callbacks.onError) {
914         try {
915           callbacks.onError(e);
916         } catch (e) {
917           console.error(e);
918         }
919       } else {
920         console.error(e);
921       }
922     }
924     if (rows && callbacks.onRow) {
925       for (let row of rows) {
926         try {
927           callbacks.onRow(row);
928         } catch (e) {
929           console.error(e);
930         }
931       }
932     }
934     try {
935       callbacks.onDone(
936         ok
937           ? Ci.nsIContentPrefCallback2.COMPLETE_OK
938           : Ci.nsIContentPrefCallback2.COMPLETE_ERROR,
939         ok,
940         rows && !!rows.length
941       );
942     } catch (e) {
943       console.error(e);
944     }
945   },
947   /**
948    * Parses the domain (the "group", to use the database's term) from the given
949    * string.
950    *
951    * @param groupStr  Assumed to be either a string or falsey.
952    * @return          If groupStr is a valid URL string, returns the domain of
953    *                  that URL.  If groupStr is some other nonempty string,
954    *                  returns groupStr itself.  Otherwise returns null.
955    *                  The return value is truncated at GROUP_NAME_MAX_LENGTH.
956    */
957   _parseGroup: function CPS2__parseGroup(groupStr) {
958     if (!groupStr) {
959       return null;
960     }
961     try {
962       var groupURI = Services.io.newURI(groupStr);
963       groupStr = HostnameGrouper_group(groupURI);
964     } catch (err) {}
965     return groupStr.substring(
966       0,
967       Ci.nsIContentPrefService2.GROUP_NAME_MAX_LENGTH - 1
968     );
969   },
971   _schedule: function CPS2__schedule(fn) {
972     Services.tm.dispatchToMainThread(fn.bind(this));
973   },
975   // A hash of arrays of observers, indexed by setting name.
976   _observers: new Map(),
978   // An array of generic observers, which observe all settings.
979   _genericObservers: new Set(),
981   addObserverForName(aName, aObserver) {
982     let observers;
983     if (aName) {
984       observers = this._observers.get(aName);
985       if (!observers) {
986         observers = new Set();
987         this._observers.set(aName, observers);
988       }
989     } else {
990       observers = this._genericObservers;
991     }
993     observers.add(aObserver);
994   },
996   removeObserverForName(aName, aObserver) {
997     let observers;
998     if (aName) {
999       observers = this._observers.get(aName);
1000       if (!observers) {
1001         return;
1002       }
1003     } else {
1004       observers = this._genericObservers;
1005     }
1007     observers.delete(aObserver);
1008   },
1010   /**
1011    * Construct a list of observers to notify about a change to some setting,
1012    * putting setting-specific observers before before generic ones, so observers
1013    * that initialize individual settings (like the page style controller)
1014    * execute before observers that display multiple settings and depend on them
1015    * being initialized first (like the content prefs sidebar).
1016    */
1017   _getObservers(aName) {
1018     let genericObserverList = Array.from(this._genericObservers);
1019     if (aName) {
1020       let observersForName = this._observers.get(aName);
1021       if (observersForName) {
1022         return Array.from(observersForName).concat(genericObserverList);
1023       }
1024     }
1025     return genericObserverList;
1026   },
1028   /**
1029    * Notify all observers about the removal of a preference.
1030    */
1031   _notifyPrefRemoved: function ContentPrefService__notifyPrefRemoved(
1032     aGroup,
1033     aName,
1034     aIsPrivate
1035   ) {
1036     for (var observer of this._getObservers(aName)) {
1037       try {
1038         observer.onContentPrefRemoved(aGroup, aName, aIsPrivate);
1039       } catch (ex) {
1040         console.error(ex);
1041       }
1042     }
1043   },
1045   /**
1046    * Notify all observers about a preference change.
1047    */
1048   _notifyPrefSet: function ContentPrefService__notifyPrefSet(
1049     aGroup,
1050     aName,
1051     aValue,
1052     aIsPrivate
1053   ) {
1054     for (var observer of this._getObservers(aName)) {
1055       try {
1056         observer.onContentPrefSet(aGroup, aName, aValue, aIsPrivate);
1057       } catch (ex) {
1058         console.error(ex);
1059       }
1060     }
1061   },
1063   extractDomain: function CPS2_extractDomain(str) {
1064     return this._parseGroup(str);
1065   },
1067   /**
1068    * Tests use this as a backchannel by calling it directly.
1069    *
1070    * @param subj   This value depends on topic.
1071    * @param topic  The backchannel "method" name.
1072    * @param data   This value depends on topic.
1073    */
1074   observe: function CPS2_observe(subj, topic, data) {
1075     switch (topic) {
1076       case "profile-before-change":
1077         this._destroy();
1078         break;
1079       case "last-pb-context-exited":
1080         this._pbStore.removeAll();
1081         break;
1082       case "test:reset":
1083         let fn = subj.QueryInterface(Ci.xpcIJSWeakReference).get();
1084         this._reset(fn);
1085         break;
1086       case "test:db":
1087         let obj = subj.QueryInterface(Ci.xpcIJSWeakReference).get();
1088         obj.value = this.conn;
1089         break;
1090     }
1091   },
1093   /**
1094    * Removes all state from the service.  Used by tests.
1095    *
1096    * @param callback  A function that will be called when done.
1097    */
1098   async _reset(callback) {
1099     this._pbStore.removeAll();
1100     this._cache.removeAll();
1102     this._observers = new Map();
1103     this._genericObservers = new Set();
1105     let tables = ["prefs", "groups", "settings"];
1106     let stmts = tables.map(t => this._stmt(`DELETE FROM ${t}`));
1107     this._execStmts(stmts, {
1108       onDone: () => {
1109         callback();
1110       },
1111     });
1112   },
1114   QueryInterface: ChromeUtils.generateQI([
1115     "nsIContentPrefService2",
1116     "nsIObserver",
1117   ]),
1119   // Database Creation & Access
1121   _dbVersion: 5,
1123   _dbSchema: {
1124     tables: {
1125       groups:
1126         "id           INTEGER PRIMARY KEY, \
1127                    name         TEXT NOT NULL",
1129       settings:
1130         "id           INTEGER PRIMARY KEY, \
1131                    name         TEXT NOT NULL",
1133       prefs:
1134         "id           INTEGER PRIMARY KEY, \
1135                    groupID      INTEGER REFERENCES groups(id), \
1136                    settingID    INTEGER NOT NULL REFERENCES settings(id), \
1137                    value        BLOB, \
1138                    timestamp    INTEGER NOT NULL DEFAULT 0", // Storage in seconds, API in ms. 0 for migrated values.
1139     },
1140     indices: {
1141       groups_idx: {
1142         table: "groups",
1143         columns: ["name"],
1144       },
1145       settings_idx: {
1146         table: "settings",
1147         columns: ["name"],
1148       },
1149       prefs_idx: {
1150         table: "prefs",
1151         columns: ["timestamp", "groupID", "settingID"],
1152       },
1153     },
1154   },
1156   _debugLog: false,
1158   log: function CPS2_log(aMessage) {
1159     if (this._debugLog) {
1160       Services.console.logStringMessage("ContentPrefService2: " + aMessage);
1161     }
1162   },
1164   async _getConnection(aAttemptNum = 0) {
1165     if (
1166       Services.startup.isInOrBeyondShutdownPhase(
1167         Ci.nsIAppStartup.SHUTDOWN_PHASE_APPSHUTDOWN
1168       )
1169     ) {
1170       throw new Error("Can't open content prefs, we're in shutdown.");
1171     }
1172     let path = PathUtils.join(PathUtils.profileDir, "content-prefs.sqlite");
1173     let conn;
1174     let resetAndRetry = async e => {
1175       if (e.result != Cr.NS_ERROR_FILE_CORRUPTED) {
1176         throw e;
1177       }
1179       if (aAttemptNum >= this.MAX_ATTEMPTS) {
1180         if (conn) {
1181           await conn.close();
1182         }
1183         this.log("Establishing connection failed too many times. Giving up.");
1184         throw e;
1185       }
1187       try {
1188         await this._failover(conn, path);
1189       } catch (e) {
1190         console.error(e);
1191         throw e;
1192       }
1193       return this._getConnection(++aAttemptNum);
1194     };
1195     try {
1196       conn = await lazy.Sqlite.openConnection({
1197         path,
1198         incrementalVacuum: true,
1199         vacuumOnIdle: true,
1200       });
1201       try {
1202         lazy.Sqlite.shutdown.addBlocker(
1203           "Closing ContentPrefService2 connection.",
1204           () => conn.close()
1205         );
1206       } catch (ex) {
1207         // Uh oh, we failed to add a shutdown blocker. Close the connection
1208         // anyway, but make sure that doesn't throw.
1209         try {
1210           await conn?.close();
1211         } catch (ex) {
1212           console.error(ex);
1213         }
1214         return null;
1215       }
1216     } catch (e) {
1217       console.error(e);
1218       return resetAndRetry(e);
1219     }
1221     try {
1222       await this._dbMaybeInit(conn);
1223     } catch (e) {
1224       console.error(e);
1225       return resetAndRetry(e);
1226     }
1228     // Turn off disk synchronization checking to reduce disk churn and speed up
1229     // operations when prefs are changed rapidly (such as when a user repeatedly
1230     // changes the value of the browser zoom setting for a site).
1231     //
1232     // Note: this could cause database corruption if the OS crashes or machine
1233     // loses power before the data gets written to disk, but this is considered
1234     // a reasonable risk for the not-so-critical data stored in this database.
1235     await conn.execute("PRAGMA synchronous = OFF");
1237     return conn;
1238   },
1240   async _failover(aConn, aPath) {
1241     this.log("Cleaning up DB file - close & remove & backup.");
1242     if (aConn) {
1243       await aConn.close();
1244     }
1245     let uniquePath = await IOUtils.createUniqueFile(
1246       PathUtils.parent(aPath),
1247       PathUtils.filename(aPath) + ".corrupt",
1248       0o600
1249     );
1250     await IOUtils.copy(aPath, uniquePath);
1251     await IOUtils.remove(aPath);
1252     this.log("Completed DB cleanup.");
1253   },
1255   _dbMaybeInit: async function CPS2__dbMaybeInit(aConn) {
1256     let version = parseInt(await aConn.getSchemaVersion(), 10);
1257     this.log("Schema version: " + version);
1259     if (version == 0) {
1260       await this._dbCreateSchema(aConn);
1261     } else if (version != this._dbVersion) {
1262       await this._dbMigrate(aConn, version, this._dbVersion);
1263     }
1264   },
1266   _createTable: async function CPS2__createTable(aConn, aName) {
1267     let tSQL = this._dbSchema.tables[aName];
1268     this.log("Creating table " + aName + " with " + tSQL);
1269     await aConn.execute(`CREATE TABLE ${aName} (${tSQL})`);
1270   },
1272   _createIndex: async function CPS2__createTable(aConn, aName) {
1273     let index = this._dbSchema.indices[aName];
1274     let statement =
1275       "CREATE INDEX IF NOT EXISTS " +
1276       aName +
1277       " ON " +
1278       index.table +
1279       "(" +
1280       index.columns.join(", ") +
1281       ")";
1282     await aConn.execute(statement);
1283   },
1285   _dbCreateSchema: async function CPS2__dbCreateSchema(aConn) {
1286     await aConn.executeTransaction(async () => {
1287       this.log("Creating DB -- tables");
1288       for (let name in this._dbSchema.tables) {
1289         await this._createTable(aConn, name);
1290       }
1292       this.log("Creating DB -- indices");
1293       for (let name in this._dbSchema.indices) {
1294         await this._createIndex(aConn, name);
1295       }
1297       await aConn.setSchemaVersion(this._dbVersion);
1298     });
1299   },
1301   _dbMigrate: async function CPS2__dbMigrate(aConn, aOldVersion, aNewVersion) {
1302     /**
1303      * Migrations should follow the template rules in bug 1074817 comment 3 which are:
1304      * 1. Migration should be incremental and non-breaking.
1305      * 2. It should be idempotent because one can downgrade an upgrade again.
1306      * On downgrade:
1307      * 1. Decrement schema version so that upgrade runs the migrations again.
1308      */
1309     await aConn.executeTransaction(async () => {
1310       for (let i = aOldVersion; i < aNewVersion; i++) {
1311         let migrationName = "_dbMigrate" + i + "To" + (i + 1);
1312         if (typeof this[migrationName] != "function") {
1313           throw new Error(
1314             "no migrator function from version " +
1315               aOldVersion +
1316               " to version " +
1317               aNewVersion
1318           );
1319         }
1320         await this[migrationName](aConn);
1321       }
1322       await aConn.setSchemaVersion(aNewVersion);
1323     });
1324   },
1326   _dbMigrate1To2: async function CPS2___dbMigrate1To2(aConn) {
1327     await aConn.execute("ALTER TABLE groups RENAME TO groupsOld");
1328     await this._createTable(aConn, "groups");
1329     await aConn.execute(`
1330       INSERT INTO groups (id, name)
1331       SELECT id, name FROM groupsOld
1332     `);
1334     await aConn.execute("DROP TABLE groupers");
1335     await aConn.execute("DROP TABLE groupsOld");
1336   },
1338   _dbMigrate2To3: async function CPS2__dbMigrate2To3(aConn) {
1339     for (let name in this._dbSchema.indices) {
1340       await this._createIndex(aConn, name);
1341     }
1342   },
1344   _dbMigrate3To4: async function CPS2__dbMigrate3To4(aConn) {
1345     // Add timestamp column if it does not exist yet. This operation is idempotent.
1346     try {
1347       await aConn.execute("SELECT timestamp FROM prefs");
1348     } catch (e) {
1349       await aConn.execute(
1350         "ALTER TABLE prefs ADD COLUMN timestamp INTEGER NOT NULL DEFAULT 0"
1351       );
1352     }
1354     // To modify prefs_idx drop it and create again.
1355     await aConn.execute("DROP INDEX IF EXISTS prefs_idx");
1356     for (let name in this._dbSchema.indices) {
1357       await this._createIndex(aConn, name);
1358     }
1359   },
1361   async _dbMigrate4To5(conn) {
1362     // This is a data migration for browser.download.lastDir. While it may not
1363     // affect all consumers, it's simpler and safer to do it here than elsewhere.
1364     await conn.execute(`
1365       DELETE FROM prefs
1366       WHERE id IN (
1367         SELECT p.id FROM prefs p
1368         JOIN groups g ON g.id = p.groupID
1369         JOIN settings s ON s.id = p.settingID
1370         WHERE s.name = 'browser.download.lastDir'
1371           AND (
1372           (g.name BETWEEN 'data:' AND 'data:' || X'FFFF') OR
1373           (g.name BETWEEN 'file:' AND 'file:' || X'FFFF')
1374         )
1375       )
1376     `);
1377     await conn.execute(`
1378       DELETE FROM groups WHERE NOT EXISTS (
1379         SELECT 1 FROM prefs WHERE groupId = groups.id
1380       )
1381     `);
1382     // Trim group names longer than MAX_GROUP_LENGTH.
1383     await conn.execute(
1384       `
1385       UPDATE groups
1386       SET name = substr(name, 0, :maxlen)
1387       WHERE LENGTH(name) > :maxlen
1388       `,
1389       {
1390         maxlen: Ci.nsIContentPrefService2.GROUP_NAME_MAX_LENGTH,
1391       }
1392     );
1393   },
1396 function checkGroupArg(group) {
1397   if (!group || typeof group != "string") {
1398     throw invalidArg("domain must be nonempty string.");
1399   }
1402 function checkNameArg(name) {
1403   if (!name || typeof name != "string") {
1404     throw invalidArg("name must be nonempty string.");
1405   }
1408 function checkValueArg(value) {
1409   if (value === undefined) {
1410     throw invalidArg("value must not be undefined.");
1411   }
1414 function checkCallbackArg(callback, required) {
1415   if (callback && !(callback instanceof Ci.nsIContentPrefCallback2)) {
1416     throw invalidArg("callback must be an nsIContentPrefCallback2.");
1417   }
1418   if (!callback && required) {
1419     throw invalidArg("callback must be given.");
1420   }
1423 function invalidArg(msg) {
1424   return Components.Exception(msg, Cr.NS_ERROR_INVALID_ARG);
1427 // XPCOM Plumbing