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
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5"use strict";
6
7const { ComponentUtils } = ChromeUtils.import(
8  "resource://gre/modules/ComponentUtils.jsm"
9);
10const { XPCOMUtils } = ChromeUtils.import(
11  "resource://gre/modules/XPCOMUtils.jsm"
12);
13const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
14const { Sqlite } = ChromeUtils.import("resource://gre/modules/Sqlite.jsm");
15const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
16
17const SCHEMA_VERSION = 1;
18const TRACKERS_BLOCKED_COUNT = "contentblocking.trackers_blocked_count";
19
20XPCOMUtils.defineLazyGetter(this, "DB_PATH", function() {
21  return OS.Path.join(OS.Constants.Path.profileDir, "protections.sqlite");
22});
23
24XPCOMUtils.defineLazyPreferenceGetter(
25  this,
26  "social_enabled",
27  "privacy.socialtracking.block_cookies.enabled",
28  false
29);
30
31XPCOMUtils.defineLazyPreferenceGetter(
32  this,
33  "milestoneMessagingEnabled",
34  "browser.contentblocking.cfr-milestone.enabled",
35  false
36);
37
38XPCOMUtils.defineLazyPreferenceGetter(
39  this,
40  "milestones",
41  "browser.contentblocking.cfr-milestone.milestones",
42  "[]",
43  null,
44  JSON.parse
45);
46
47XPCOMUtils.defineLazyPreferenceGetter(
48  this,
49  "oldMilestone",
50  "browser.contentblocking.cfr-milestone.milestone-achieved",
51  0
52);
53
54// How often we check if the user is eligible for seeing a "milestone"
55// doorhanger. 24 hours by default.
56XPCOMUtils.defineLazyPreferenceGetter(
57  this,
58  "MILESTONE_UPDATE_INTERVAL",
59  "browser.contentblocking.cfr-milestone.update-interval",
60  24 * 60 * 60 * 1000
61);
62
63XPCOMUtils.defineLazyModuleGetters(this, {
64  AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
65  DeferredTask: "resource://gre/modules/DeferredTask.jsm",
66});
67
68/**
69 * All SQL statements should be defined here.
70 */
71const SQL = {
72  createEvents:
73    "CREATE TABLE events (" +
74    "id INTEGER PRIMARY KEY, " +
75    "type INTEGER NOT NULL, " +
76    "count INTEGER NOT NULL, " +
77    "timestamp DATE " +
78    ");",
79
80  addEvent:
81    "INSERT INTO events (type, count, timestamp) " +
82    "VALUES (:type, 1, date(:date));",
83
84  incrementEvent: "UPDATE events SET count = count + 1 WHERE id = :id;",
85
86  selectByTypeAndDate:
87    "SELECT * FROM events " +
88    "WHERE type = :type " +
89    "AND timestamp = date(:date);",
90
91  deleteEventsRecords: "DELETE FROM events;",
92
93  removeRecordsSince: "DELETE FROM events WHERE timestamp >= date(:date);",
94
95  selectByDateRange:
96    "SELECT * FROM events " +
97    "WHERE timestamp BETWEEN date(:dateFrom) AND date(:dateTo);",
98
99  sumAllEvents: "SELECT sum(count) FROM events;",
100
101  getEarliestDate:
102    "SELECT timestamp FROM events ORDER BY timestamp ASC LIMIT 1;",
103};
104
105/**
106 * Creates the database schema.
107 */
108async function createDatabase(db) {
109  await db.execute(SQL.createEvents);
110}
111
112async function removeAllRecords(db) {
113  await db.execute(SQL.deleteEventsRecords);
114}
115
116async function removeRecordsSince(db, date) {
117  await db.execute(SQL.removeRecordsSince, { date });
118}
119
120this.TrackingDBService = function() {
121  this._initPromise = this._initialize();
122};
123
124TrackingDBService.prototype = {
125  classID: Components.ID("{3c9c43b6-09eb-4ed2-9b87-e29f4221eef0}"),
126  QueryInterface: ChromeUtils.generateQI(["nsITrackingDBService"]),
127  _xpcom_factory: ComponentUtils.generateSingletonFactory(TrackingDBService),
128  // This is the connection to the database, opened in _initialize and closed on _shutdown.
129  _db: null,
130  waitingTasks: new Set(),
131  finishedShutdown: true,
132
133  async ensureDB() {
134    await this._initPromise;
135    return this._db;
136  },
137
138  async _initialize() {
139    let db = await Sqlite.openConnection({ path: DB_PATH });
140
141    try {
142      // Check to see if we need to perform any migrations.
143      let dbVersion = parseInt(await db.getSchemaVersion());
144
145      // getSchemaVersion() returns a 0 int if the schema
146      // version is undefined.
147      if (dbVersion === 0) {
148        await createDatabase(db);
149      } else if (dbVersion < SCHEMA_VERSION) {
150        // TODO
151        // await upgradeDatabase(db, dbVersion, SCHEMA_VERSION);
152      }
153
154      await db.setSchemaVersion(SCHEMA_VERSION);
155    } catch (e) {
156      // Close the DB connection before passing the exception to the consumer.
157      await db.close();
158      throw e;
159    }
160
161    AsyncShutdown.profileBeforeChange.addBlocker(
162      "TrackingDBService: Shutting down the content blocking database.",
163      () => this._shutdown()
164    );
165    this.finishedShutdown = false;
166    this._db = db;
167  },
168
169  async _shutdown() {
170    let db = await this.ensureDB();
171    this.finishedShutdown = true;
172    await Promise.all(Array.from(this.waitingTasks, task => task.finalize()));
173    await db.close();
174  },
175
176  async recordContentBlockingLog(data) {
177    if (this.finishedShutdown) {
178      // The database has already been closed.
179      return;
180    }
181    let task = new DeferredTask(async () => {
182      try {
183        await this.saveEvents(data);
184      } finally {
185        this.waitingTasks.delete(task);
186      }
187    }, 0);
188    task.arm();
189    this.waitingTasks.add(task);
190  },
191
192  identifyType(events) {
193    let result = null;
194    let isTracker = false;
195    for (let [state, blocked] of events) {
196      if (
197        state &
198          Ci.nsIWebProgressListener.STATE_LOADED_LEVEL_1_TRACKING_CONTENT ||
199        state & Ci.nsIWebProgressListener.STATE_LOADED_LEVEL_2_TRACKING_CONTENT
200      ) {
201        isTracker = true;
202      }
203      if (blocked) {
204        if (
205          state & Ci.nsIWebProgressListener.STATE_BLOCKED_FINGERPRINTING_CONTENT
206        ) {
207          result = Ci.nsITrackingDBService.FINGERPRINTERS_ID;
208        } else if (
209          // If STP is enabled and either a social tracker or cookie is blocked.
210          social_enabled &&
211          (state &
212            Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_SOCIALTRACKER ||
213            state &
214              Ci.nsIWebProgressListener.STATE_BLOCKED_SOCIALTRACKING_CONTENT)
215        ) {
216          result = Ci.nsITrackingDBService.SOCIAL_ID;
217        } else if (
218          // If there is a tracker blocked. If there is a social tracker blocked, but STP is not enabled.
219          state & Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT ||
220          state & Ci.nsIWebProgressListener.STATE_BLOCKED_SOCIALTRACKING_CONTENT
221        ) {
222          result = Ci.nsITrackingDBService.TRACKERS_ID;
223        } else if (
224          // If a tracking cookie was blocked attribute it to tracking cookies.
225          // This includes social tracking cookies since STP is not enabled.
226          state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_TRACKER ||
227          state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_SOCIALTRACKER
228        ) {
229          result = Ci.nsITrackingDBService.TRACKING_COOKIES_ID;
230        } else if (
231          state &
232            Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_BY_PERMISSION ||
233          state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_ALL ||
234          state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_FOREIGN
235        ) {
236          result = Ci.nsITrackingDBService.OTHER_COOKIES_BLOCKED_ID;
237        } else if (
238          state & Ci.nsIWebProgressListener.STATE_BLOCKED_CRYPTOMINING_CONTENT
239        ) {
240          result = Ci.nsITrackingDBService.CRYPTOMINERS_ID;
241        }
242      }
243    }
244    // if a cookie is blocked for any reason, and it is identified as a tracker,
245    // then add to the tracking cookies count.
246    if (
247      result == Ci.nsITrackingDBService.OTHER_COOKIES_BLOCKED_ID &&
248      isTracker
249    ) {
250      result = Ci.nsITrackingDBService.TRACKING_COOKIES_ID;
251    }
252
253    return result;
254  },
255
256  /**
257   * Saves data rows to the DB.
258   * @param data
259   *        An array of JS objects representing row items to save.
260   */
261  async saveEvents(data) {
262    let db = await this.ensureDB();
263    let log = JSON.parse(data);
264    try {
265      await db.executeTransaction(async () => {
266        for (let thirdParty in log) {
267          // "type" will be undefined if there is no blocking event, or 0 if it is a
268          // cookie which is not a tracking cookie. These should not be added to the database.
269          let type = this.identifyType(log[thirdParty]);
270          if (type) {
271            // Send the blocked event to Telemetry
272            Services.telemetry.scalarAdd(TRACKERS_BLOCKED_COUNT, 1);
273
274            // today is a date "YYY-MM-DD" which can compare with what is
275            // already saved in the database.
276            let today = new Date().toISOString().split("T")[0];
277            let row = await db.executeCached(SQL.selectByTypeAndDate, {
278              type,
279              date: today,
280            });
281            let todayEntry = row[0];
282
283            // If previous events happened today (local time), aggregate them.
284            if (todayEntry) {
285              let id = todayEntry.getResultByName("id");
286              await db.executeCached(SQL.incrementEvent, { id });
287            } else {
288              // Event is created on a new day, add a new entry.
289              await db.executeCached(SQL.addEvent, { type, date: today });
290            }
291          }
292        }
293      });
294    } catch (e) {
295      Cu.reportError(e);
296    }
297
298    // If milestone CFR messaging is not enabled we don't need to update the milestone pref or send the event.
299    // We don't do this check too frequently, for performance reasons.
300    if (
301      !milestoneMessagingEnabled ||
302      (this.lastChecked &&
303        Date.now() - this.lastChecked < MILESTONE_UPDATE_INTERVAL)
304    ) {
305      return;
306    }
307    this.lastChecked = Date.now();
308    let totalSaved = await this.sumAllEvents();
309
310    let reachedMilestone = null;
311    let nextMilestone = null;
312    for (let [index, milestone] of milestones.entries()) {
313      if (totalSaved >= milestone) {
314        reachedMilestone = milestone;
315        nextMilestone = milestones[index + 1];
316      }
317    }
318
319    // Show the milestone message if the user is not too close to the next milestone.
320    // Or if there is no next milestone.
321    if (
322      reachedMilestone &&
323      (!nextMilestone || nextMilestone - totalSaved > 3000) &&
324      (!oldMilestone || oldMilestone < reachedMilestone)
325    ) {
326      Services.obs.notifyObservers(
327        {
328          wrappedJSObject: {
329            event: "ContentBlockingMilestone",
330          },
331        },
332        "SiteProtection:ContentBlockingMilestone"
333      );
334    }
335  },
336
337  async clearAll() {
338    let db = await this.ensureDB();
339    await removeAllRecords(db);
340  },
341
342  async clearSince(date) {
343    let db = await this.ensureDB();
344    date = new Date(date).toISOString();
345    await removeRecordsSince(db, date);
346  },
347
348  async getEventsByDateRange(dateFrom, dateTo) {
349    let db = await this.ensureDB();
350    dateFrom = new Date(dateFrom).toISOString();
351    dateTo = new Date(dateTo).toISOString();
352    return db.execute(SQL.selectByDateRange, { dateFrom, dateTo });
353  },
354
355  async sumAllEvents() {
356    let db = await this.ensureDB();
357    let results = await db.execute(SQL.sumAllEvents);
358    if (!results[0]) {
359      return 0;
360    }
361    let total = results[0].getResultByName("sum(count)");
362    return total || 0;
363  },
364
365  async getEarliestRecordedDate() {
366    let db = await this.ensureDB();
367    let date = await db.execute(SQL.getEarliestDate);
368    if (!date[0]) {
369      return null;
370    }
371    let earliestDate = date[0].getResultByName("timestamp");
372
373    // All of our dates are recorded as 00:00 GMT, add 12 hours to the timestamp
374    // to ensure we display the correct date no matter the user's location.
375    let hoursInMS12 = 12 * 60 * 60 * 1000;
376    let earliestDateInMS = new Date(earliestDate).getTime() + hoursInMS12;
377
378    return earliestDateInMS || null;
379  },
380};
381
382var EXPORTED_SYMBOLS = ["TrackingDBService"];
383