Files
sousa-gecko/toolkit/components/antitracking/TrackingDBService.sys.mjs
T
Tim Huang a64b828f0c Bug 2043202 - Use a shared promise to ensure concurrent call to _flushLiveLogs(). r=emz
This patch changes to use a shared promise in _flushLiveLogs to ensure
concurrent calls to this function will all wait until the data is
flushed. This patch fixes one issue that the second call into the
tracking DB service APIs will return stale data.

Differential Revision: https://phabricator.services.mozilla.com/D303720
2026-06-01 16:18:02 +00:00

490 lines
15 KiB
JavaScript

/* 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 lazy = {};
ChromeUtils.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,
"flushOnQueryEnabled",
"browser.contentblocking.database.flushOnQuery.enabled",
true
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"fpp_enabled",
"privacy.fingerprintingProtection",
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,
_flushingLiveLogsPromise: null,
async ensureDB() {
await this._initPromise;
return this._db;
},
async _initialize() {
if (Services.startup.shuttingDown) {
// Skip initialization once shutdown has begun
return;
}
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;
// Ensure all waiting tasks are finalized before closing the DB
await Promise.all(
Array.from(this.waitingTasks, task =>
task.isFinalized ? null : 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);
},
/**
* Force every live top-level WindowGlobalParent to flush its in-memory
* ContentBlockingLog to the database, then wait for those writes to settle.
* Called before read paths so long-lived tabs contribute up-to-date data.
*
*/
async _flushLiveLogs() {
if (this.finishedShutdown || !lazy.flushOnQueryEnabled) {
return undefined;
}
// Concurrent callers share a single in-flight flush: if a flush is already
// running, additional callers await the same promise rather than triggering
// a redundant flush or returning early and reading stale data.
if (this._flushingLiveLogsPromise) {
return this._flushingLiveLogsPromise;
}
const { promise: flushPromise, resolve, reject } = Promise.withResolvers();
this._flushingLiveLogsPromise = flushPromise;
try {
WindowGlobalParent.flushAllContentBlockingLogs();
// Drain until empty rather than awaiting a single snapshot. ActorDestroy
// on a tab teardown also routes through recordContentBlockingLog and can
// add new tasks to waitingTasks while we're awaiting the current batch;
// those would otherwise be missed by both this caller and any concurrent
// callers sharing the in-flight flush promise.
while (this.waitingTasks.size) {
await Promise.all([...this.waitingTasks].map(task => task.finalize()));
}
resolve();
} catch (e) {
reject(e);
throw e;
} finally {
this._flushingLiveLogsPromise = null;
}
return undefined;
},
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 (
lazy.fpp_enabled &&
state &
Ci.nsIWebProgressListener.STATE_BLOCKED_SUSPICIOUS_FINGERPRINTING
) {
// The suspicious fingerprinting event gets filed in standard windows
// regardless of whether the fingerprinting protection is enabled. To
// avoid recording the case where our protection doesn't apply, we
// only record blocking suspicious fingerprinting if the
// fingerprinting protection is enabled in the normal windows.
//
// TODO(Bug 1864909): We don't need to check if fingerprinting
// protection is enabled once the event only gets filed when
// fingerprinting protection is enabled for the context.
result = Ci.nsITrackingDBService.SUSPICIOUS_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, attribute it to trackers. Social
// tracker blocks also fall through to here when STP is not enabled.
// We also attribute replaced tracking content and email tracker
// blocks to trackers.
state & Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT ||
state &
Ci.nsIWebProgressListener.STATE_BLOCKED_SOCIALTRACKING_CONTENT ||
state & Ci.nsIWebProgressListener.STATE_REPLACED_TRACKING_CONTENT ||
state & Ci.nsIWebProgressListener.STATE_BLOCKED_EMAILTRACKING_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 ||
state & Ci.nsIWebProgressListener.STATE_COOKIES_PARTITIONED_TRACKER
) {
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;
} else if (
state & Ci.nsIWebProgressListener.STATE_PURGED_BOUNCETRACKER
) {
result = Ci.nsITrackingDBService.BOUNCETRACKERS_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
Glean.contentblocking.trackersBlockedCount.add(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();
// Query the DB directly rather than going through sumAllEvents(): we're
// inside the flush triggered by _flushLiveLogs, and re-entering it would
// deadlock on the in-flight shared promise.
let totalSaved = await this.sumAllEventsWithoutFlushing(db);
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();
// Ensure all waiting tasks are finalized before clearing the DB.
await Promise.all(
Array.from(this.waitingTasks, task =>
task.isFinalized ? null : task.finalize()
)
);
await removeAllRecords(db);
},
async clearSince(date) {
let db = await this.ensureDB();
// Ensure all waiting tasks are finalized before clearing the DB.
await Promise.all(
Array.from(this.waitingTasks, task =>
task.isFinalized ? null : task.finalize()
)
);
date = new Date(date).toISOString();
await removeRecordsSince(db, date);
},
async getEventsByDateRange(dateFrom, dateTo) {
let db = await this.ensureDB();
await this._flushLiveLogs();
dateFrom = new Date(dateFrom).toISOString();
dateTo = new Date(dateTo).toISOString();
return db.execute(SQL.selectByDateRange, { dateFrom, dateTo });
},
async sumAllEvents() {
let db = await this.ensureDB();
await this._flushLiveLogs();
return this.sumAllEventsWithoutFlushing(db);
},
async sumAllEventsWithoutFlushing(db) {
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();
await this._flushLiveLogs();
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;
},
};