diff options
Diffstat (limited to 'toolkit/components/antitracking/TrackingDBService.sys.mjs')
-rw-r--r-- | toolkit/components/antitracking/TrackingDBService.sys.mjs | 375 |
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; + }, +}; |