summaryrefslogtreecommitdiffstats
path: root/toolkit/components/antitracking/TrackingDBService.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/antitracking/TrackingDBService.sys.mjs')
-rw-r--r--toolkit/components/antitracking/TrackingDBService.sys.mjs375
1 files changed, 375 insertions, 0 deletions
diff --git a/toolkit/components/antitracking/TrackingDBService.sys.mjs b/toolkit/components/antitracking/TrackingDBService.sys.mjs
new file mode 100644
index 0000000000..9f3b952a65
--- /dev/null
+++ b/toolkit/components/antitracking/TrackingDBService.sys.mjs
@@ -0,0 +1,375 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { Sqlite } from "resource://gre/modules/Sqlite.sys.mjs";
+
+const SCHEMA_VERSION = 1;
+const TRACKERS_BLOCKED_COUNT = "contentblocking.trackers_blocked_count";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "DB_PATH", function () {
+ return PathUtils.join(PathUtils.profileDir, "protections.sqlite");
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "social_enabled",
+ "privacy.socialtracking.block_cookies.enabled",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "milestoneMessagingEnabled",
+ "browser.contentblocking.cfr-milestone.enabled",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "milestones",
+ "browser.contentblocking.cfr-milestone.milestones",
+ "[]",
+ null,
+ JSON.parse
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "oldMilestone",
+ "browser.contentblocking.cfr-milestone.milestone-achieved",
+ 0
+);
+
+// How often we check if the user is eligible for seeing a "milestone"
+// doorhanger. 24 hours by default.
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "MILESTONE_UPDATE_INTERVAL",
+ "browser.contentblocking.cfr-milestone.update-interval",
+ 24 * 60 * 60 * 1000
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+});
+
+/**
+ * All SQL statements should be defined here.
+ */
+const SQL = {
+ createEvents:
+ "CREATE TABLE events (" +
+ "id INTEGER PRIMARY KEY, " +
+ "type INTEGER NOT NULL, " +
+ "count INTEGER NOT NULL, " +
+ "timestamp DATE " +
+ ");",
+
+ addEvent:
+ "INSERT INTO events (type, count, timestamp) " +
+ "VALUES (:type, 1, date(:date));",
+
+ incrementEvent: "UPDATE events SET count = count + 1 WHERE id = :id;",
+
+ selectByTypeAndDate:
+ "SELECT * FROM events " +
+ "WHERE type = :type " +
+ "AND timestamp = date(:date);",
+
+ deleteEventsRecords: "DELETE FROM events;",
+
+ removeRecordsSince: "DELETE FROM events WHERE timestamp >= date(:date);",
+
+ selectByDateRange:
+ "SELECT * FROM events " +
+ "WHERE timestamp BETWEEN date(:dateFrom) AND date(:dateTo);",
+
+ sumAllEvents: "SELECT sum(count) FROM events;",
+
+ getEarliestDate:
+ "SELECT timestamp FROM events ORDER BY timestamp ASC LIMIT 1;",
+};
+
+/**
+ * Creates the database schema.
+ */
+async function createDatabase(db) {
+ await db.execute(SQL.createEvents);
+}
+
+async function removeAllRecords(db) {
+ await db.execute(SQL.deleteEventsRecords);
+}
+
+async function removeRecordsSince(db, date) {
+ await db.execute(SQL.removeRecordsSince, { date });
+}
+
+export function TrackingDBService() {
+ this._initPromise = this._initialize();
+}
+
+TrackingDBService.prototype = {
+ classID: Components.ID("{3c9c43b6-09eb-4ed2-9b87-e29f4221eef0}"),
+ QueryInterface: ChromeUtils.generateQI(["nsITrackingDBService"]),
+ // This is the connection to the database, opened in _initialize and closed on _shutdown.
+ _db: null,
+ waitingTasks: new Set(),
+ finishedShutdown: true,
+
+ async ensureDB() {
+ await this._initPromise;
+ return this._db;
+ },
+
+ async _initialize() {
+ let db = await Sqlite.openConnection({ path: lazy.DB_PATH });
+
+ try {
+ // Check to see if we need to perform any migrations.
+ let dbVersion = parseInt(await db.getSchemaVersion());
+
+ // getSchemaVersion() returns a 0 int if the schema
+ // version is undefined.
+ if (dbVersion === 0) {
+ await createDatabase(db);
+ } else if (dbVersion < SCHEMA_VERSION) {
+ // TODO
+ // await upgradeDatabase(db, dbVersion, SCHEMA_VERSION);
+ }
+
+ await db.setSchemaVersion(SCHEMA_VERSION);
+ } catch (e) {
+ // Close the DB connection before passing the exception to the consumer.
+ await db.close();
+ throw e;
+ }
+
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ "TrackingDBService: Shutting down the content blocking database.",
+ () => this._shutdown()
+ );
+ this.finishedShutdown = false;
+ this._db = db;
+ },
+
+ async _shutdown() {
+ let db = await this.ensureDB();
+ this.finishedShutdown = true;
+ await Promise.all(Array.from(this.waitingTasks, task => task.finalize()));
+ await db.close();
+ },
+
+ async recordContentBlockingLog(data) {
+ if (this.finishedShutdown) {
+ // The database has already been closed.
+ return;
+ }
+ let task = new lazy.DeferredTask(async () => {
+ try {
+ await this.saveEvents(data);
+ } finally {
+ this.waitingTasks.delete(task);
+ }
+ }, 0);
+ task.arm();
+ this.waitingTasks.add(task);
+ },
+
+ identifyType(events) {
+ let result = null;
+ let isTracker = false;
+ for (let [state, blocked] of events) {
+ if (
+ state &
+ Ci.nsIWebProgressListener.STATE_LOADED_LEVEL_1_TRACKING_CONTENT ||
+ state & Ci.nsIWebProgressListener.STATE_LOADED_LEVEL_2_TRACKING_CONTENT
+ ) {
+ isTracker = true;
+ }
+ if (blocked) {
+ if (
+ state &
+ Ci.nsIWebProgressListener.STATE_BLOCKED_FINGERPRINTING_CONTENT ||
+ state &
+ Ci.nsIWebProgressListener.STATE_REPLACED_FINGERPRINTING_CONTENT
+ ) {
+ result = Ci.nsITrackingDBService.FINGERPRINTERS_ID;
+ } else if (
+ // If STP is enabled and either a social tracker or cookie is blocked.
+ lazy.social_enabled &&
+ (state &
+ Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_SOCIALTRACKER ||
+ state &
+ Ci.nsIWebProgressListener.STATE_BLOCKED_SOCIALTRACKING_CONTENT)
+ ) {
+ result = Ci.nsITrackingDBService.SOCIAL_ID;
+ } else if (
+ // If there is a tracker blocked. If there is a social tracker blocked, but STP is not enabled.
+ state & Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT ||
+ state & Ci.nsIWebProgressListener.STATE_BLOCKED_SOCIALTRACKING_CONTENT
+ ) {
+ result = Ci.nsITrackingDBService.TRACKERS_ID;
+ } else if (
+ // If a tracking cookie was blocked attribute it to tracking cookies.
+ // This includes social tracking cookies since STP is not enabled.
+ state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_TRACKER ||
+ state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_SOCIALTRACKER
+ ) {
+ result = Ci.nsITrackingDBService.TRACKING_COOKIES_ID;
+ } else if (
+ state &
+ Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_BY_PERMISSION ||
+ state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_ALL ||
+ state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_FOREIGN
+ ) {
+ result = Ci.nsITrackingDBService.OTHER_COOKIES_BLOCKED_ID;
+ } else if (
+ state & Ci.nsIWebProgressListener.STATE_BLOCKED_CRYPTOMINING_CONTENT
+ ) {
+ result = Ci.nsITrackingDBService.CRYPTOMINERS_ID;
+ }
+ }
+ }
+ // if a cookie is blocked for any reason, and it is identified as a tracker,
+ // then add to the tracking cookies count.
+ if (
+ result == Ci.nsITrackingDBService.OTHER_COOKIES_BLOCKED_ID &&
+ isTracker
+ ) {
+ result = Ci.nsITrackingDBService.TRACKING_COOKIES_ID;
+ }
+
+ return result;
+ },
+
+ /**
+ * Saves data rows to the DB.
+ * @param data
+ * An array of JS objects representing row items to save.
+ */
+ async saveEvents(data) {
+ let db = await this.ensureDB();
+ let log = JSON.parse(data);
+ try {
+ await db.executeTransaction(async () => {
+ for (let thirdParty in log) {
+ // "type" will be undefined if there is no blocking event, or 0 if it is a
+ // cookie which is not a tracking cookie. These should not be added to the database.
+ let type = this.identifyType(log[thirdParty]);
+ if (type) {
+ // Send the blocked event to Telemetry
+ Services.telemetry.scalarAdd(TRACKERS_BLOCKED_COUNT, 1);
+
+ // today is a date "YYY-MM-DD" which can compare with what is
+ // already saved in the database.
+ let today = new Date().toISOString().split("T")[0];
+ let row = await db.executeCached(SQL.selectByTypeAndDate, {
+ type,
+ date: today,
+ });
+ let todayEntry = row[0];
+
+ // If previous events happened today (local time), aggregate them.
+ if (todayEntry) {
+ let id = todayEntry.getResultByName("id");
+ await db.executeCached(SQL.incrementEvent, { id });
+ } else {
+ // Event is created on a new day, add a new entry.
+ await db.executeCached(SQL.addEvent, { type, date: today });
+ }
+ }
+ }
+ });
+ } catch (e) {
+ console.error(e);
+ }
+
+ // If milestone CFR messaging is not enabled we don't need to update the milestone pref or send the event.
+ // We don't do this check too frequently, for performance reasons.
+ if (
+ !lazy.milestoneMessagingEnabled ||
+ (this.lastChecked &&
+ Date.now() - this.lastChecked < lazy.MILESTONE_UPDATE_INTERVAL)
+ ) {
+ return;
+ }
+ this.lastChecked = Date.now();
+ let totalSaved = await this.sumAllEvents();
+
+ let reachedMilestone = null;
+ let nextMilestone = null;
+ for (let [index, milestone] of lazy.milestones.entries()) {
+ if (totalSaved >= milestone) {
+ reachedMilestone = milestone;
+ nextMilestone = lazy.milestones[index + 1];
+ }
+ }
+
+ // Show the milestone message if the user is not too close to the next milestone.
+ // Or if there is no next milestone.
+ if (
+ reachedMilestone &&
+ (!nextMilestone || nextMilestone - totalSaved > 3000) &&
+ (!lazy.oldMilestone || lazy.oldMilestone < reachedMilestone)
+ ) {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ event: "ContentBlockingMilestone",
+ },
+ },
+ "SiteProtection:ContentBlockingMilestone"
+ );
+ }
+ },
+
+ async clearAll() {
+ let db = await this.ensureDB();
+ await removeAllRecords(db);
+ },
+
+ async clearSince(date) {
+ let db = await this.ensureDB();
+ date = new Date(date).toISOString();
+ await removeRecordsSince(db, date);
+ },
+
+ async getEventsByDateRange(dateFrom, dateTo) {
+ let db = await this.ensureDB();
+ dateFrom = new Date(dateFrom).toISOString();
+ dateTo = new Date(dateTo).toISOString();
+ return db.execute(SQL.selectByDateRange, { dateFrom, dateTo });
+ },
+
+ async sumAllEvents() {
+ let db = await this.ensureDB();
+ let results = await db.execute(SQL.sumAllEvents);
+ if (!results[0]) {
+ return 0;
+ }
+ let total = results[0].getResultByName("sum(count)");
+ return total || 0;
+ },
+
+ async getEarliestRecordedDate() {
+ let db = await this.ensureDB();
+ let date = await db.execute(SQL.getEarliestDate);
+ if (!date[0]) {
+ return null;
+ }
+ let earliestDate = date[0].getResultByName("timestamp");
+
+ // All of our dates are recorded as 00:00 GMT, add 12 hours to the timestamp
+ // to ensure we display the correct date no matter the user's location.
+ let hoursInMS12 = 12 * 60 * 60 * 1000;
+ let earliestDateInMS = new Date(earliestDate).getTime() + hoursInMS12;
+
+ return earliestDateInMS || null;
+ },
+};