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 EXPORTED_SYMBOLS = ["FirefoxMonitor"];
8
9const { XPCOMUtils } = ChromeUtils.import(
10  "resource://gre/modules/XPCOMUtils.jsm"
11);
12
13XPCOMUtils.defineLazyModuleGetters(this, {
14  EveryWindow: "resource:///modules/EveryWindow.jsm",
15  PluralForm: "resource://gre/modules/PluralForm.jsm",
16  Preferences: "resource://gre/modules/Preferences.jsm",
17  RemoteSettings: "resource://services-settings/remote-settings.js",
18  Services: "resource://gre/modules/Services.jsm",
19});
20
21const STYLESHEET = "chrome://browser/content/fxmonitor/FirefoxMonitor.css";
22const ICON = "chrome://browser/content/fxmonitor/monitor32.svg";
23
24this.FirefoxMonitor = {
25  // Map of breached site host -> breach metadata.
26  domainMap: new Map(),
27
28  // Reference to the extension object from the WebExtension context.
29  // Used for getting URIs for resources packaged in the extension.
30  extension: null,
31
32  // Whether we've started observing for the user visiting a breached site.
33  observerAdded: false,
34
35  // This is here for documentation, will be redefined to a lazy getter
36  // that creates and returns a string bundle in loadStrings().
37  strings: null,
38
39  // This is here for documentation, will be redefined to a pref getter
40  // using XPCOMUtils.defineLazyPreferenceGetter in init().
41  enabled: null,
42
43  kEnabledPref: "extensions.fxmonitor.enabled",
44
45  // This is here for documentation, will be redefined to a pref getter
46  // using XPCOMUtils.defineLazyPreferenceGetter in delayedInit().
47  // Telemetry event recording is enabled by default.
48  // If this pref exists and is true-y, it's disabled.
49  telemetryDisabled: null,
50  kTelemetryDisabledPref: "extensions.fxmonitor.telemetryDisabled",
51
52  kNotificationID: "fxmonitor",
53
54  // This is here for documentation, will be redefined to a pref getter
55  // using XPCOMUtils.defineLazyPreferenceGetter in delayedInit().
56  // The value of this property is used as the URL to which the user
57  // is directed when they click "Check Firefox Monitor".
58  FirefoxMonitorURL: null,
59  kFirefoxMonitorURLPref: "extensions.fxmonitor.FirefoxMonitorURL",
60  kDefaultFirefoxMonitorURL: "https://monitor.firefox.com",
61
62  // This is here for documentation, will be redefined to a pref getter
63  // using XPCOMUtils.defineLazyPreferenceGetter in delayedInit().
64  // The pref stores whether the user has seen a breach alert already.
65  // The value is used in warnIfNeeded.
66  firstAlertShown: null,
67  kFirstAlertShownPref: "extensions.fxmonitor.firstAlertShown",
68
69  disable() {
70    Preferences.set(this.kEnabledPref, false);
71  },
72
73  getString(aKey) {
74    return this.strings.GetStringFromName(aKey);
75  },
76
77  getFormattedString(aKey, args) {
78    return this.strings.formatStringFromName(aKey, args);
79  },
80
81  // We used to persist the list of hosts we've already warned the
82  // user for in this pref. Now, we check the pref at init and
83  // if it has a value, migrate the remembered hosts to content prefs
84  // and clear this one.
85  kWarnedHostsPref: "extensions.fxmonitor.warnedHosts",
86  migrateWarnedHostsIfNeeded() {
87    if (!Preferences.isSet(this.kWarnedHostsPref)) {
88      return;
89    }
90
91    let hosts = [];
92    try {
93      hosts = JSON.parse(Preferences.get(this.kWarnedHostsPref));
94    } catch (ex) {
95      // Invalid JSON, nothing to be done.
96    }
97
98    let loadContext = Cu.createLoadContext();
99    for (let host of hosts) {
100      this.rememberWarnedHost(loadContext, host);
101    }
102
103    Preferences.reset(this.kWarnedHostsPref);
104  },
105
106  init() {
107    XPCOMUtils.defineLazyPreferenceGetter(
108      this,
109      "enabled",
110      this.kEnabledPref,
111      true,
112      (pref, oldVal, newVal) => {
113        if (newVal) {
114          this.startObserving();
115        } else {
116          this.stopObserving();
117        }
118      }
119    );
120
121    if (this.enabled) {
122      this.startObserving();
123    }
124  },
125
126  // Used to enforce idempotency of delayedInit. delayedInit is
127  // called in startObserving() to ensure we load our strings, etc.
128  _delayedInited: false,
129  async delayedInit() {
130    if (this._delayedInited) {
131      return;
132    }
133
134    this._delayedInited = true;
135
136    XPCOMUtils.defineLazyServiceGetter(
137      this,
138      "_contentPrefService",
139      "@mozilla.org/content-pref/service;1",
140      "nsIContentPrefService2"
141    );
142
143    this.migrateWarnedHostsIfNeeded();
144
145    // Expire our telemetry on November 1, at which time
146    // we should redo data-review.
147    let telemetryExpiryDate = new Date(2019, 10, 1); // Month is zero-index
148    let today = new Date();
149    let expired = today.getTime() > telemetryExpiryDate.getTime();
150
151    Services.telemetry.registerEvents("fxmonitor", {
152      interaction: {
153        methods: ["interaction"],
154        objects: [
155          "doorhanger_shown",
156          "doorhanger_removed",
157          "check_btn",
158          "dismiss_btn",
159          "never_show_btn",
160        ],
161        record_on_release: true,
162        expired,
163      },
164    });
165
166    let telemetryEnabled = !Preferences.get(this.kTelemetryDisabledPref);
167    Services.telemetry.setEventRecordingEnabled("fxmonitor", telemetryEnabled);
168
169    XPCOMUtils.defineLazyPreferenceGetter(
170      this,
171      "FirefoxMonitorURL",
172      this.kFirefoxMonitorURLPref,
173      this.kDefaultFirefoxMonitorURL
174    );
175
176    XPCOMUtils.defineLazyPreferenceGetter(
177      this,
178      "firstAlertShown",
179      this.kFirstAlertShownPref,
180      false
181    );
182
183    XPCOMUtils.defineLazyGetter(this, "strings", () => {
184      return Services.strings.createBundle(
185        "chrome://browser/locale/fxmonitor.properties"
186      );
187    });
188
189    XPCOMUtils.defineLazyPreferenceGetter(
190      this,
191      "telemetryDisabled",
192      this.kTelemetryDisabledPref,
193      false
194    );
195
196    await this.loadBreaches();
197  },
198
199  kRemoteSettingsKey: "fxmonitor-breaches",
200  async loadBreaches() {
201    let populateSites = data => {
202      this.domainMap.clear();
203      data.forEach(site => {
204        if (
205          !site.Domain ||
206          !site.Name ||
207          !site.PwnCount ||
208          !site.BreachDate ||
209          !site.AddedDate
210        ) {
211          Cu.reportError(
212            `Firefox Monitor: malformed breach entry.\nSite:\n${JSON.stringify(
213              site
214            )}`
215          );
216          return;
217        }
218
219        try {
220          this.domainMap.set(site.Domain, {
221            Name: site.Name,
222            PwnCount: site.PwnCount,
223            Year: new Date(site.BreachDate).getFullYear(),
224            AddedDate: site.AddedDate.split("T")[0],
225          });
226        } catch (e) {
227          Cu.reportError(
228            `Firefox Monitor: malformed breach entry.\nSite:\n${JSON.stringify(
229              site
230            )}\nError:\n${e}`
231          );
232        }
233      });
234    };
235
236    RemoteSettings(this.kRemoteSettingsKey).on("sync", event => {
237      let {
238        data: { current },
239      } = event;
240      populateSites(current);
241    });
242
243    let data = await RemoteSettings(this.kRemoteSettingsKey).get();
244    if (data && data.length) {
245      populateSites(data);
246    }
247  },
248
249  // nsIWebProgressListener implementation.
250  onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) {
251    if (
252      !(aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) ||
253      !aWebProgress.isTopLevel ||
254      aWebProgress.isLoadingDocument ||
255      !Components.isSuccessCode(aStatus)
256    ) {
257      return;
258    }
259
260    let host;
261    try {
262      host = Services.eTLD.getBaseDomain(aRequest.URI);
263    } catch (e) {
264      // If we can't get the host for the URL, it's not one we
265      // care about for breach alerts anyway.
266      return;
267    }
268
269    this.warnIfNeeded(aBrowser, host);
270  },
271
272  notificationsByWindow: new WeakMap(),
273  panelUIsByWindow: new WeakMap(),
274
275  async startObserving() {
276    if (this.observerAdded) {
277      return;
278    }
279
280    EveryWindow.registerCallback(
281      this.kNotificationID,
282      win => {
283        if (this.notificationsByWindow.has(win)) {
284          // We've already set up this window.
285          return;
286        }
287
288        this.notificationsByWindow.set(win, new Set());
289
290        // Start listening across all tabs! The UI will
291        // be set up lazily when we actually need to show
292        // a notification.
293        this.delayedInit().then(() => {
294          win.gBrowser.addTabsProgressListener(this);
295        });
296      },
297      (win, closing) => {
298        // If the window is going away, don't bother doing anything.
299        if (closing) {
300          return;
301        }
302
303        let DOMWindowUtils = win.windowUtils;
304        DOMWindowUtils.removeSheetUsingURIString(
305          STYLESHEET,
306          DOMWindowUtils.AUTHOR_SHEET
307        );
308
309        if (this.notificationsByWindow.has(win)) {
310          this.notificationsByWindow.get(win).forEach(n => {
311            n.remove();
312          });
313          this.notificationsByWindow.delete(win);
314        }
315
316        if (this.panelUIsByWindow.has(win)) {
317          let doc = win.document;
318          doc
319            .getElementById(`${this.kNotificationID}-notification-anchor`)
320            .remove();
321          doc.getElementById(`${this.kNotificationID}-notification`).remove();
322          this.panelUIsByWindow.delete(win);
323        }
324
325        win.gBrowser.removeTabsProgressListener(this);
326      }
327    );
328
329    this.observerAdded = true;
330  },
331
332  setupPanelUI(win) {
333    // Inject our stylesheet.
334    let DOMWindowUtils = win.windowUtils;
335    DOMWindowUtils.loadSheetUsingURIString(
336      STYLESHEET,
337      DOMWindowUtils.AUTHOR_SHEET
338    );
339
340    // Setup the popup notification stuff. First, the URL bar icon:
341    let doc = win.document;
342    let notificationBox = doc.getElementById("notification-popup-box");
343    // We create a box to use as the anchor, and put an icon image
344    // inside it. This way, when we animate the icon, its scale change
345    // does not cause the popup notification to bounce due to the anchor
346    // point moving.
347    let anchorBox = doc.createXULElement("box");
348    anchorBox.setAttribute("id", `${this.kNotificationID}-notification-anchor`);
349    anchorBox.classList.add("notification-anchor-icon");
350    let img = doc.createXULElement("image");
351    img.setAttribute("role", "button");
352    img.classList.add(`${this.kNotificationID}-icon`);
353    img.style.listStyleImage = `url(${ICON})`;
354    anchorBox.appendChild(img);
355    notificationBox.appendChild(anchorBox);
356    img.setAttribute(
357      "tooltiptext",
358      this.getFormattedString("fxmonitor.anchorIcon.tooltiptext", [
359        this.getString("fxmonitor.brandName"),
360      ])
361    );
362
363    // Now, the popupnotificationcontent:
364    let parentElt = doc.defaultView.PopupNotifications.panel.parentNode;
365    let pn = doc.createXULElement("popupnotification");
366    let pnContent = doc.createXULElement("popupnotificationcontent");
367    let panelUI = new PanelUI(doc);
368    pnContent.appendChild(panelUI.box);
369    pn.appendChild(pnContent);
370    pn.setAttribute("id", `${this.kNotificationID}-notification`);
371    pn.setAttribute("hidden", "true");
372    parentElt.appendChild(pn);
373    this.panelUIsByWindow.set(win, panelUI);
374    return panelUI;
375  },
376
377  stopObserving() {
378    if (!this.observerAdded) {
379      return;
380    }
381
382    EveryWindow.unregisterCallback(this.kNotificationID);
383
384    this.observerAdded = false;
385  },
386
387  async hostAlreadyWarned(loadContext, host) {
388    return new Promise((resolve, reject) => {
389      this._contentPrefService.getByDomainAndName(
390        host,
391        "extensions.fxmonitor.hostAlreadyWarned",
392        loadContext,
393        {
394          handleCompletion: () => resolve(false),
395          handleResult: result => resolve(result.value),
396        }
397      );
398    });
399  },
400
401  rememberWarnedHost(loadContext, host) {
402    this._contentPrefService.set(
403      host,
404      "extensions.fxmonitor.hostAlreadyWarned",
405      true,
406      loadContext
407    );
408  },
409
410  async warnIfNeeded(browser, host) {
411    if (
412      !this.enabled ||
413      !this.domainMap.has(host) ||
414      (await this.hostAlreadyWarned(browser.loadContext, host))
415    ) {
416      return;
417    }
418
419    let site = this.domainMap.get(host);
420
421    // We only alert for breaches that were found up to 2 months ago,
422    // except for the very first alert we show the user - in which case,
423    // we include breaches found in the last three years.
424    let breachDateThreshold = new Date();
425    if (this.firstAlertShown) {
426      breachDateThreshold.setMonth(breachDateThreshold.getMonth() - 2);
427    } else {
428      breachDateThreshold.setFullYear(breachDateThreshold.getFullYear() - 1);
429    }
430
431    if (new Date(site.AddedDate).getTime() < breachDateThreshold.getTime()) {
432      return;
433    } else if (!this.firstAlertShown) {
434      Preferences.set(this.kFirstAlertShownPref, true);
435    }
436
437    this.rememberWarnedHost(browser.loadContext, host);
438
439    let doc = browser.ownerDocument;
440    let win = doc.defaultView;
441    let panelUI = this.panelUIsByWindow.get(win);
442    if (!panelUI) {
443      panelUI = this.setupPanelUI(win);
444    }
445
446    let animatedOnce = false;
447    let populatePanel = event => {
448      switch (event) {
449        case "showing":
450          panelUI.refresh(site);
451          if (animatedOnce) {
452            // If we've already animated once for this site, don't animate again.
453            doc
454              .getElementById("notification-popup")
455              .setAttribute("fxmonitoranimationdone", "true");
456            doc
457              .getElementById(`${this.kNotificationID}-notification-anchor`)
458              .setAttribute("fxmonitoranimationdone", "true");
459            break;
460          }
461          // Make sure we animate if we're coming from another tab that has
462          // this attribute set.
463          doc
464            .getElementById("notification-popup")
465            .removeAttribute("fxmonitoranimationdone");
466          doc
467            .getElementById(`${this.kNotificationID}-notification-anchor`)
468            .removeAttribute("fxmonitoranimationdone");
469          break;
470        case "shown":
471          animatedOnce = true;
472          break;
473        case "removed":
474          this.notificationsByWindow
475            .get(win)
476            .delete(
477              win.PopupNotifications.getNotification(
478                this.kNotificationID,
479                browser
480              )
481            );
482          this.recordEvent("doorhanger_removed");
483          break;
484      }
485    };
486
487    let n = win.PopupNotifications.show(
488      browser,
489      this.kNotificationID,
490      "",
491      `${this.kNotificationID}-notification-anchor`,
492      panelUI.primaryAction,
493      panelUI.secondaryActions,
494      {
495        persistent: true,
496        hideClose: true,
497        eventCallback: populatePanel,
498        popupIconURL: ICON,
499      }
500    );
501
502    this.recordEvent("doorhanger_shown");
503
504    this.notificationsByWindow.get(win).add(n);
505  },
506
507  recordEvent(aEventName) {
508    if (this.telemetryDisabled) {
509      return;
510    }
511
512    Services.telemetry.recordEvent("fxmonitor", "interaction", aEventName);
513  },
514};
515
516function PanelUI(doc) {
517  this.site = null;
518  this.doc = doc;
519
520  let box = doc.createXULElement("vbox");
521
522  let elt = doc.createXULElement("description");
523  elt.textContent = this.getString("fxmonitor.popupHeader");
524  elt.classList.add("headerText");
525  box.appendChild(elt);
526
527  elt = doc.createXULElement("description");
528  elt.classList.add("popupText");
529  box.appendChild(elt);
530
531  this.box = box;
532}
533
534PanelUI.prototype = {
535  getString(aKey) {
536    return FirefoxMonitor.getString(aKey);
537  },
538
539  getFormattedString(aKey, args) {
540    return FirefoxMonitor.getFormattedString(aKey, args);
541  },
542
543  get brandString() {
544    if (this._brandString) {
545      return this._brandString;
546    }
547    return (this._brandString = this.getString("fxmonitor.brandName"));
548  },
549
550  getFirefoxMonitorURL: aSiteName => {
551    return `${FirefoxMonitor.FirefoxMonitorURL}/?breach=${encodeURIComponent(
552      aSiteName
553    )}&utm_source=firefox&utm_medium=popup`;
554  },
555
556  get primaryAction() {
557    if (this._primaryAction) {
558      return this._primaryAction;
559    }
560    return (this._primaryAction = {
561      label: this.getFormattedString("fxmonitor.checkButton.label", [
562        this.brandString,
563      ]),
564      accessKey: this.getString("fxmonitor.checkButton.accessKey"),
565      callback: () => {
566        let win = this.doc.defaultView;
567        win.openTrustedLinkIn(
568          this.getFirefoxMonitorURL(this.site.Name),
569          "tab",
570          {}
571        );
572
573        FirefoxMonitor.recordEvent("check_btn");
574      },
575    });
576  },
577
578  get secondaryActions() {
579    if (this._secondaryActions) {
580      return this._secondaryActions;
581    }
582    return (this._secondaryActions = [
583      {
584        label: this.getString("fxmonitor.dismissButton.label"),
585        accessKey: this.getString("fxmonitor.dismissButton.accessKey"),
586        callback: () => {
587          FirefoxMonitor.recordEvent("dismiss_btn");
588        },
589      },
590      {
591        label: this.getFormattedString("fxmonitor.neverShowButton.label", [
592          this.brandString,
593        ]),
594        accessKey: this.getString("fxmonitor.neverShowButton.accessKey"),
595        callback: () => {
596          FirefoxMonitor.disable();
597          FirefoxMonitor.recordEvent("never_show_btn");
598        },
599      },
600    ]);
601  },
602
603  refresh(site) {
604    this.site = site;
605
606    let elt = this.box.querySelector(".popupText");
607
608    // If > 100k, the PwnCount is rounded down to the most significant
609    // digit and prefixed with "More than".
610    // Ex.: 12,345 -> 12,345
611    //      234,567 -> More than 200,000
612    //      345,678,901 -> More than 300,000,000
613    //      4,567,890,123 -> More than 4,000,000,000
614    let k100k = 100000;
615    let pwnCount = site.PwnCount;
616    let stringName = "fxmonitor.popupText";
617    if (pwnCount > k100k) {
618      let multiplier = 1;
619      while (pwnCount >= 10) {
620        pwnCount /= 10;
621        multiplier *= 10;
622      }
623      pwnCount = Math.floor(pwnCount) * multiplier;
624      stringName = "fxmonitor.popupTextRounded";
625    }
626
627    elt.textContent = PluralForm.get(pwnCount, this.getString(stringName))
628      .replace("#1", pwnCount.toLocaleString())
629      .replace("#2", site.Name)
630      .replace("#3", site.Year)
631      .replace("#4", this.brandString);
632  },
633};
634