summaryrefslogtreecommitdiffstats
path: root/browser/components/fxmonitor
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /browser/components/fxmonitor
parentInitial commit. (diff)
downloadfirefox-upstream.tar.xz
firefox-upstream.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/fxmonitor')
-rw-r--r--browser/components/fxmonitor/FirefoxMonitor.jsm633
-rw-r--r--browser/components/fxmonitor/content/FirefoxMonitor.css90
-rw-r--r--browser/components/fxmonitor/content/monitor32.svg6
-rw-r--r--browser/components/fxmonitor/jar.mn8
-rw-r--r--browser/components/fxmonitor/moz.build16
-rw-r--r--browser/components/fxmonitor/test/browser/browser.ini3
-rw-r--r--browser/components/fxmonitor/test/browser/browser_fxmonitor_doorhanger.js254
7 files changed, 1010 insertions, 0 deletions
diff --git a/browser/components/fxmonitor/FirefoxMonitor.jsm b/browser/components/fxmonitor/FirefoxMonitor.jsm
new file mode 100644
index 0000000000..a8f4ac98b7
--- /dev/null
+++ b/browser/components/fxmonitor/FirefoxMonitor.jsm
@@ -0,0 +1,633 @@
+/* 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/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["FirefoxMonitor"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ EveryWindow: "resource:///modules/EveryWindow.jsm",
+ PluralForm: "resource://gre/modules/PluralForm.jsm",
+ Preferences: "resource://gre/modules/Preferences.jsm",
+ RemoteSettings: "resource://services-settings/remote-settings.js",
+ Services: "resource://gre/modules/Services.jsm",
+});
+
+const STYLESHEET = "chrome://browser/content/fxmonitor/FirefoxMonitor.css";
+const ICON = "chrome://browser/content/fxmonitor/monitor32.svg";
+
+this.FirefoxMonitor = {
+ // Map of breached site host -> breach metadata.
+ domainMap: new Map(),
+
+ // Reference to the extension object from the WebExtension context.
+ // Used for getting URIs for resources packaged in the extension.
+ extension: null,
+
+ // Whether we've started observing for the user visiting a breached site.
+ observerAdded: false,
+
+ // This is here for documentation, will be redefined to a lazy getter
+ // that creates and returns a string bundle in loadStrings().
+ strings: null,
+
+ // This is here for documentation, will be redefined to a pref getter
+ // using XPCOMUtils.defineLazyPreferenceGetter in init().
+ enabled: null,
+
+ kEnabledPref: "extensions.fxmonitor.enabled",
+
+ // This is here for documentation, will be redefined to a pref getter
+ // using XPCOMUtils.defineLazyPreferenceGetter in delayedInit().
+ // Telemetry event recording is enabled by default.
+ // If this pref exists and is true-y, it's disabled.
+ telemetryDisabled: null,
+ kTelemetryDisabledPref: "extensions.fxmonitor.telemetryDisabled",
+
+ kNotificationID: "fxmonitor",
+
+ // This is here for documentation, will be redefined to a pref getter
+ // using XPCOMUtils.defineLazyPreferenceGetter in delayedInit().
+ // The value of this property is used as the URL to which the user
+ // is directed when they click "Check Firefox Monitor".
+ FirefoxMonitorURL: null,
+ kFirefoxMonitorURLPref: "extensions.fxmonitor.FirefoxMonitorURL",
+ kDefaultFirefoxMonitorURL: "https://monitor.firefox.com",
+
+ // This is here for documentation, will be redefined to a pref getter
+ // using XPCOMUtils.defineLazyPreferenceGetter in delayedInit().
+ // The pref stores whether the user has seen a breach alert already.
+ // The value is used in warnIfNeeded.
+ firstAlertShown: null,
+ kFirstAlertShownPref: "extensions.fxmonitor.firstAlertShown",
+
+ disable() {
+ Preferences.set(this.kEnabledPref, false);
+ },
+
+ getString(aKey) {
+ return this.strings.GetStringFromName(aKey);
+ },
+
+ getFormattedString(aKey, args) {
+ return this.strings.formatStringFromName(aKey, args);
+ },
+
+ // We used to persist the list of hosts we've already warned the
+ // user for in this pref. Now, we check the pref at init and
+ // if it has a value, migrate the remembered hosts to content prefs
+ // and clear this one.
+ kWarnedHostsPref: "extensions.fxmonitor.warnedHosts",
+ migrateWarnedHostsIfNeeded() {
+ if (!Preferences.isSet(this.kWarnedHostsPref)) {
+ return;
+ }
+
+ let hosts = [];
+ try {
+ hosts = JSON.parse(Preferences.get(this.kWarnedHostsPref));
+ } catch (ex) {
+ // Invalid JSON, nothing to be done.
+ }
+
+ let loadContext = Cu.createLoadContext();
+ for (let host of hosts) {
+ this.rememberWarnedHost(loadContext, host);
+ }
+
+ Preferences.reset(this.kWarnedHostsPref);
+ },
+
+ init() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "enabled",
+ this.kEnabledPref,
+ true,
+ (pref, oldVal, newVal) => {
+ if (newVal) {
+ this.startObserving();
+ } else {
+ this.stopObserving();
+ }
+ }
+ );
+
+ if (this.enabled) {
+ this.startObserving();
+ }
+ },
+
+ // Used to enforce idempotency of delayedInit. delayedInit is
+ // called in startObserving() to ensure we load our strings, etc.
+ _delayedInited: false,
+ async delayedInit() {
+ if (this._delayedInited) {
+ return;
+ }
+
+ this._delayedInited = true;
+
+ XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "_contentPrefService",
+ "@mozilla.org/content-pref/service;1",
+ "nsIContentPrefService2"
+ );
+
+ this.migrateWarnedHostsIfNeeded();
+
+ // Expire our telemetry on November 1, at which time
+ // we should redo data-review.
+ let telemetryExpiryDate = new Date(2019, 10, 1); // Month is zero-index
+ let today = new Date();
+ let expired = today.getTime() > telemetryExpiryDate.getTime();
+
+ Services.telemetry.registerEvents("fxmonitor", {
+ interaction: {
+ methods: ["interaction"],
+ objects: [
+ "doorhanger_shown",
+ "doorhanger_removed",
+ "check_btn",
+ "dismiss_btn",
+ "never_show_btn",
+ ],
+ record_on_release: true,
+ expired,
+ },
+ });
+
+ let telemetryEnabled = !Preferences.get(this.kTelemetryDisabledPref);
+ Services.telemetry.setEventRecordingEnabled("fxmonitor", telemetryEnabled);
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "FirefoxMonitorURL",
+ this.kFirefoxMonitorURLPref,
+ this.kDefaultFirefoxMonitorURL
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "firstAlertShown",
+ this.kFirstAlertShownPref,
+ false
+ );
+
+ XPCOMUtils.defineLazyGetter(this, "strings", () => {
+ return Services.strings.createBundle(
+ "chrome://browser/locale/fxmonitor.properties"
+ );
+ });
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "telemetryDisabled",
+ this.kTelemetryDisabledPref,
+ false
+ );
+
+ await this.loadBreaches();
+ },
+
+ kRemoteSettingsKey: "fxmonitor-breaches",
+ async loadBreaches() {
+ let populateSites = data => {
+ this.domainMap.clear();
+ data.forEach(site => {
+ if (
+ !site.Domain ||
+ !site.Name ||
+ !site.PwnCount ||
+ !site.BreachDate ||
+ !site.AddedDate
+ ) {
+ Cu.reportError(
+ `Firefox Monitor: malformed breach entry.\nSite:\n${JSON.stringify(
+ site
+ )}`
+ );
+ return;
+ }
+
+ try {
+ this.domainMap.set(site.Domain, {
+ Name: site.Name,
+ PwnCount: site.PwnCount,
+ Year: new Date(site.BreachDate).getFullYear(),
+ AddedDate: site.AddedDate.split("T")[0],
+ });
+ } catch (e) {
+ Cu.reportError(
+ `Firefox Monitor: malformed breach entry.\nSite:\n${JSON.stringify(
+ site
+ )}\nError:\n${e}`
+ );
+ }
+ });
+ };
+
+ RemoteSettings(this.kRemoteSettingsKey).on("sync", event => {
+ let {
+ data: { current },
+ } = event;
+ populateSites(current);
+ });
+
+ let data = await RemoteSettings(this.kRemoteSettingsKey).get();
+ if (data && data.length) {
+ populateSites(data);
+ }
+ },
+
+ // nsIWebProgressListener implementation.
+ onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) {
+ if (
+ !(aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) ||
+ !aWebProgress.isTopLevel ||
+ aWebProgress.isLoadingDocument ||
+ !Components.isSuccessCode(aStatus)
+ ) {
+ return;
+ }
+
+ let host;
+ try {
+ host = Services.eTLD.getBaseDomain(aRequest.URI);
+ } catch (e) {
+ // If we can't get the host for the URL, it's not one we
+ // care about for breach alerts anyway.
+ return;
+ }
+
+ this.warnIfNeeded(aBrowser, host);
+ },
+
+ notificationsByWindow: new WeakMap(),
+ panelUIsByWindow: new WeakMap(),
+
+ async startObserving() {
+ if (this.observerAdded) {
+ return;
+ }
+
+ EveryWindow.registerCallback(
+ this.kNotificationID,
+ win => {
+ if (this.notificationsByWindow.has(win)) {
+ // We've already set up this window.
+ return;
+ }
+
+ this.notificationsByWindow.set(win, new Set());
+
+ // Start listening across all tabs! The UI will
+ // be set up lazily when we actually need to show
+ // a notification.
+ this.delayedInit().then(() => {
+ win.gBrowser.addTabsProgressListener(this);
+ });
+ },
+ (win, closing) => {
+ // If the window is going away, don't bother doing anything.
+ if (closing) {
+ return;
+ }
+
+ let DOMWindowUtils = win.windowUtils;
+ DOMWindowUtils.removeSheetUsingURIString(
+ STYLESHEET,
+ DOMWindowUtils.AUTHOR_SHEET
+ );
+
+ if (this.notificationsByWindow.has(win)) {
+ this.notificationsByWindow.get(win).forEach(n => {
+ n.remove();
+ });
+ this.notificationsByWindow.delete(win);
+ }
+
+ if (this.panelUIsByWindow.has(win)) {
+ let doc = win.document;
+ doc
+ .getElementById(`${this.kNotificationID}-notification-anchor`)
+ .remove();
+ doc.getElementById(`${this.kNotificationID}-notification`).remove();
+ this.panelUIsByWindow.delete(win);
+ }
+
+ win.gBrowser.removeTabsProgressListener(this);
+ }
+ );
+
+ this.observerAdded = true;
+ },
+
+ setupPanelUI(win) {
+ // Inject our stylesheet.
+ let DOMWindowUtils = win.windowUtils;
+ DOMWindowUtils.loadSheetUsingURIString(
+ STYLESHEET,
+ DOMWindowUtils.AUTHOR_SHEET
+ );
+
+ // Setup the popup notification stuff. First, the URL bar icon:
+ let doc = win.document;
+ let notificationBox = doc.getElementById("notification-popup-box");
+ // We create a box to use as the anchor, and put an icon image
+ // inside it. This way, when we animate the icon, its scale change
+ // does not cause the popup notification to bounce due to the anchor
+ // point moving.
+ let anchorBox = doc.createXULElement("box");
+ anchorBox.setAttribute("id", `${this.kNotificationID}-notification-anchor`);
+ anchorBox.classList.add("notification-anchor-icon");
+ let img = doc.createXULElement("image");
+ img.setAttribute("role", "button");
+ img.classList.add(`${this.kNotificationID}-icon`);
+ img.style.listStyleImage = `url(${ICON})`;
+ anchorBox.appendChild(img);
+ notificationBox.appendChild(anchorBox);
+ img.setAttribute(
+ "tooltiptext",
+ this.getFormattedString("fxmonitor.anchorIcon.tooltiptext", [
+ this.getString("fxmonitor.brandName"),
+ ])
+ );
+
+ // Now, the popupnotificationcontent:
+ let parentElt = doc.defaultView.PopupNotifications.panel.parentNode;
+ let pn = doc.createXULElement("popupnotification");
+ let pnContent = doc.createXULElement("popupnotificationcontent");
+ let panelUI = new PanelUI(doc);
+ pnContent.appendChild(panelUI.box);
+ pn.appendChild(pnContent);
+ pn.setAttribute("id", `${this.kNotificationID}-notification`);
+ pn.setAttribute("hidden", "true");
+ parentElt.appendChild(pn);
+ this.panelUIsByWindow.set(win, panelUI);
+ return panelUI;
+ },
+
+ stopObserving() {
+ if (!this.observerAdded) {
+ return;
+ }
+
+ EveryWindow.unregisterCallback(this.kNotificationID);
+
+ this.observerAdded = false;
+ },
+
+ async hostAlreadyWarned(loadContext, host) {
+ return new Promise((resolve, reject) => {
+ this._contentPrefService.getByDomainAndName(
+ host,
+ "extensions.fxmonitor.hostAlreadyWarned",
+ loadContext,
+ {
+ handleCompletion: () => resolve(false),
+ handleResult: result => resolve(result.value),
+ }
+ );
+ });
+ },
+
+ rememberWarnedHost(loadContext, host) {
+ this._contentPrefService.set(
+ host,
+ "extensions.fxmonitor.hostAlreadyWarned",
+ true,
+ loadContext
+ );
+ },
+
+ async warnIfNeeded(browser, host) {
+ if (
+ !this.enabled ||
+ !this.domainMap.has(host) ||
+ (await this.hostAlreadyWarned(browser.loadContext, host))
+ ) {
+ return;
+ }
+
+ let site = this.domainMap.get(host);
+
+ // We only alert for breaches that were found up to 2 months ago,
+ // except for the very first alert we show the user - in which case,
+ // we include breaches found in the last three years.
+ let breachDateThreshold = new Date();
+ if (this.firstAlertShown) {
+ breachDateThreshold.setMonth(breachDateThreshold.getMonth() - 2);
+ } else {
+ breachDateThreshold.setFullYear(breachDateThreshold.getFullYear() - 1);
+ }
+
+ if (new Date(site.AddedDate).getTime() < breachDateThreshold.getTime()) {
+ return;
+ } else if (!this.firstAlertShown) {
+ Preferences.set(this.kFirstAlertShownPref, true);
+ }
+
+ this.rememberWarnedHost(browser.loadContext, host);
+
+ let doc = browser.ownerDocument;
+ let win = doc.defaultView;
+ let panelUI = this.panelUIsByWindow.get(win);
+ if (!panelUI) {
+ panelUI = this.setupPanelUI(win);
+ }
+
+ let animatedOnce = false;
+ let populatePanel = event => {
+ switch (event) {
+ case "showing":
+ panelUI.refresh(site);
+ if (animatedOnce) {
+ // If we've already animated once for this site, don't animate again.
+ doc
+ .getElementById("notification-popup")
+ .setAttribute("fxmonitoranimationdone", "true");
+ doc
+ .getElementById(`${this.kNotificationID}-notification-anchor`)
+ .setAttribute("fxmonitoranimationdone", "true");
+ break;
+ }
+ // Make sure we animate if we're coming from another tab that has
+ // this attribute set.
+ doc
+ .getElementById("notification-popup")
+ .removeAttribute("fxmonitoranimationdone");
+ doc
+ .getElementById(`${this.kNotificationID}-notification-anchor`)
+ .removeAttribute("fxmonitoranimationdone");
+ break;
+ case "shown":
+ animatedOnce = true;
+ break;
+ case "removed":
+ this.notificationsByWindow
+ .get(win)
+ .delete(
+ win.PopupNotifications.getNotification(
+ this.kNotificationID,
+ browser
+ )
+ );
+ this.recordEvent("doorhanger_removed");
+ break;
+ }
+ };
+
+ let n = win.PopupNotifications.show(
+ browser,
+ this.kNotificationID,
+ "",
+ `${this.kNotificationID}-notification-anchor`,
+ panelUI.primaryAction,
+ panelUI.secondaryActions,
+ {
+ persistent: true,
+ hideClose: true,
+ eventCallback: populatePanel,
+ popupIconURL: ICON,
+ }
+ );
+
+ this.recordEvent("doorhanger_shown");
+
+ this.notificationsByWindow.get(win).add(n);
+ },
+
+ recordEvent(aEventName) {
+ if (this.telemetryDisabled) {
+ return;
+ }
+
+ Services.telemetry.recordEvent("fxmonitor", "interaction", aEventName);
+ },
+};
+
+function PanelUI(doc) {
+ this.site = null;
+ this.doc = doc;
+
+ let box = doc.createXULElement("vbox");
+
+ let elt = doc.createXULElement("description");
+ elt.textContent = this.getString("fxmonitor.popupHeader");
+ elt.classList.add("headerText");
+ box.appendChild(elt);
+
+ elt = doc.createXULElement("description");
+ elt.classList.add("popupText");
+ box.appendChild(elt);
+
+ this.box = box;
+}
+
+PanelUI.prototype = {
+ getString(aKey) {
+ return FirefoxMonitor.getString(aKey);
+ },
+
+ getFormattedString(aKey, args) {
+ return FirefoxMonitor.getFormattedString(aKey, args);
+ },
+
+ get brandString() {
+ if (this._brandString) {
+ return this._brandString;
+ }
+ return (this._brandString = this.getString("fxmonitor.brandName"));
+ },
+
+ getFirefoxMonitorURL: aSiteName => {
+ return `${FirefoxMonitor.FirefoxMonitorURL}/?breach=${encodeURIComponent(
+ aSiteName
+ )}&utm_source=firefox&utm_medium=popup`;
+ },
+
+ get primaryAction() {
+ if (this._primaryAction) {
+ return this._primaryAction;
+ }
+ return (this._primaryAction = {
+ label: this.getFormattedString("fxmonitor.checkButton.label", [
+ this.brandString,
+ ]),
+ accessKey: this.getString("fxmonitor.checkButton.accessKey"),
+ callback: () => {
+ let win = this.doc.defaultView;
+ win.openTrustedLinkIn(
+ this.getFirefoxMonitorURL(this.site.Name),
+ "tab",
+ {}
+ );
+
+ FirefoxMonitor.recordEvent("check_btn");
+ },
+ });
+ },
+
+ get secondaryActions() {
+ if (this._secondaryActions) {
+ return this._secondaryActions;
+ }
+ return (this._secondaryActions = [
+ {
+ label: this.getString("fxmonitor.dismissButton.label"),
+ accessKey: this.getString("fxmonitor.dismissButton.accessKey"),
+ callback: () => {
+ FirefoxMonitor.recordEvent("dismiss_btn");
+ },
+ },
+ {
+ label: this.getFormattedString("fxmonitor.neverShowButton.label", [
+ this.brandString,
+ ]),
+ accessKey: this.getString("fxmonitor.neverShowButton.accessKey"),
+ callback: () => {
+ FirefoxMonitor.disable();
+ FirefoxMonitor.recordEvent("never_show_btn");
+ },
+ },
+ ]);
+ },
+
+ refresh(site) {
+ this.site = site;
+
+ let elt = this.box.querySelector(".popupText");
+
+ // If > 100k, the PwnCount is rounded down to the most significant
+ // digit and prefixed with "More than".
+ // Ex.: 12,345 -> 12,345
+ // 234,567 -> More than 200,000
+ // 345,678,901 -> More than 300,000,000
+ // 4,567,890,123 -> More than 4,000,000,000
+ let k100k = 100000;
+ let pwnCount = site.PwnCount;
+ let stringName = "fxmonitor.popupText";
+ if (pwnCount > k100k) {
+ let multiplier = 1;
+ while (pwnCount >= 10) {
+ pwnCount /= 10;
+ multiplier *= 10;
+ }
+ pwnCount = Math.floor(pwnCount) * multiplier;
+ stringName = "fxmonitor.popupTextRounded";
+ }
+
+ elt.textContent = PluralForm.get(pwnCount, this.getString(stringName))
+ .replace("#1", pwnCount.toLocaleString())
+ .replace("#2", site.Name)
+ .replace("#3", site.Year)
+ .replace("#4", this.brandString);
+ },
+};
diff --git a/browser/components/fxmonitor/content/FirefoxMonitor.css b/browser/components/fxmonitor/content/FirefoxMonitor.css
new file mode 100644
index 0000000000..69b6975781
--- /dev/null
+++ b/browser/components/fxmonitor/content/FirefoxMonitor.css
@@ -0,0 +1,90 @@
+/* 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/. */
+
+#fxmonitor-notification popupnotificationcontent {
+ margin-top: 0;
+}
+
+#fxmonitor-notification .popup-notification-body > :not(popupnotificationcontent) {
+ display: none;
+}
+
+.fxmonitor-icon {
+ width: 16px;
+ height: 16px;
+}
+
+#fxmonitor-notification-anchor,
+.fxmonitor-icon {
+ animation-timing-function: linear;
+ animation-duration: 0.66s;
+}
+
+/* We only want to animate the icon/doorhanger the first time it's shown for a site.
+ An attribute fxmonitoranimationdone is used to control this from FirefoxMonitor.jsm */
+#fxmonitor-notification-anchor:not([fxmonitoranimationdone]) {
+ animation-name: fxmonitor-anchor-animation;
+}
+
+#fxmonitor-notification-anchor:not([fxmonitoranimationdone]):-moz-locale-dir(rtl) {
+ animation-name: fxmonitor-anchor-animation-rtl;
+}
+
+#fxmonitor-notification-anchor:not([fxmonitoranimationdone]) .fxmonitor-icon {
+ animation-name: fxmonitor-icon-animation;
+}
+
+#notification-popup[popupid=fxmonitor]:not([fxmonitoranimationdone]) {
+ transition-delay: 0.33s;
+}
+
+/* Animate the appearance of the anchor icon: push the other icons to the right. */
+@keyframes fxmonitor-anchor-animation {
+ from {
+ margin-right: -20px;
+ }
+ 50% {
+ margin-right: 0;
+ }
+ to {
+ }
+}
+
+/* For RTL locales, push the other icons to the left. */
+@keyframes fxmonitor-anchor-animation-rtl {
+ from {
+ margin-left: -20px;
+ }
+ 50% {
+ margin-left: 0;
+ }
+ to {
+ }
+}
+
+/* After the appearance of the anchor box, expand the icon into view */
+@keyframes fxmonitor-icon-animation {
+ from {
+ transform: scale(0);
+ opacity: 0;
+ }
+ 50% {
+ transform: scale(0);
+ opacity: 0;
+ }
+ 75% {
+ transform: scale(1.2);
+ }
+ to {
+ }
+}
+
+#fxmonitor-notification .popupText {
+ max-width: 300px;
+}
+
+#fxmonitor-notification .headerText {
+ font-weight: 600;
+ white-space: pre;
+}
diff --git a/browser/components/fxmonitor/content/monitor32.svg b/browser/components/fxmonitor/content/monitor32.svg
new file mode 100644
index 0000000000..ffc3902ad5
--- /dev/null
+++ b/browser/components/fxmonitor/content/monitor32.svg
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" fill-rule="nonzero">
+ <path d="M27.188 6.714L24 4.874 16.738.684l-.227-.13a3.866 3.866 0 0 0-3.846 0l-.228.13-10.228 5.9-.227.13a3.859 3.859 0 0 0-1.927 3.335V22.382c0 1.372.739 2.646 1.927 3.335l10.449 6.03a1.608 1.608 0 0 0 2.19-.585 1.606 1.606 0 0 0-.584-2.19L3.815 23.071a1.103 1.103 0 0 1-.547-.954V10.314c0-.394.209-.757.547-.954l1.81-1.046 8.418-4.862c.339-.197.757-.19 1.095 0l10.228 5.902c.339.197.548.56.548.954V22.11c0 .394-.21.757-.548.954l-3.458 2-1.748-2.653a8.317 8.317 0 0 0 2.77-6.197c0-4.597-3.742-8.338-8.34-8.338-4.596 0-8.338 3.741-8.338 8.338 0 4.597 3.736 8.339 8.333 8.339.984 0 1.932-.172 2.812-.492l2.652 4.03c.043.062.086.123.136.179.006.012.018.018.03.03.056.062.117.117.179.167.018.012.03.024.05.037.073.055.147.098.227.141.018.006.037.019.055.025.068.03.142.061.216.08.018.006.03.012.049.012.086.025.172.037.258.043.025 0 .05.006.074.006.025 0 .05.006.074.006.05 0 .098-.006.148-.012.024 0 .043 0 .067-.006a1.43 1.43 0 0 0 .271-.062c.025-.006.05-.018.068-.024.067-.025.135-.056.203-.092.012-.007.03-.013.043-.019l4.997-2.886a3.859 3.859 0 0 0 1.926-3.335V10.049a3.885 3.885 0 0 0-1.932-3.335zM9.452 16.215a5.137 5.137 0 0 1 5.133-5.132 5.137 5.137 0 0 1 5.132 5.132 5.137 5.137 0 0 1-5.132 5.133 5.137 5.137 0 0 1-5.133-5.133z"/>
+</svg>
diff --git a/browser/components/fxmonitor/jar.mn b/browser/components/fxmonitor/jar.mn
new file mode 100644
index 0000000000..c21d8ac6fb
--- /dev/null
+++ b/browser/components/fxmonitor/jar.mn
@@ -0,0 +1,8 @@
+#filter substitution
+# 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/.
+
+browser.jar:
+ content/browser/fxmonitor/FirefoxMonitor.css (content/FirefoxMonitor.css)
+ content/browser/fxmonitor/monitor32.svg (content/monitor32.svg)
diff --git a/browser/components/fxmonitor/moz.build b/browser/components/fxmonitor/moz.build
new file mode 100644
index 0000000000..1315cd4f5b
--- /dev/null
+++ b/browser/components/fxmonitor/moz.build
@@ -0,0 +1,16 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Firefox Monitor")
+
+EXTRA_JS_MODULES += [
+ "FirefoxMonitor.jsm",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"]
diff --git a/browser/components/fxmonitor/test/browser/browser.ini b/browser/components/fxmonitor/test/browser/browser.ini
new file mode 100644
index 0000000000..613bd0e1af
--- /dev/null
+++ b/browser/components/fxmonitor/test/browser/browser.ini
@@ -0,0 +1,3 @@
+[browser_fxmonitor_doorhanger.js]
+skip-if = debug # bug 1547517
+tags = remote-settings
diff --git a/browser/components/fxmonitor/test/browser/browser_fxmonitor_doorhanger.js b/browser/components/fxmonitor/test/browser/browser_fxmonitor_doorhanger.js
new file mode 100644
index 0000000000..ccc2aa9e1d
--- /dev/null
+++ b/browser/components/fxmonitor/test/browser/browser_fxmonitor_doorhanger.js
@@ -0,0 +1,254 @@
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonManager",
+ "resource://gre/modules/AddonManager.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "RemoteSettings",
+ "resource://services-settings/remote-settings.js"
+);
+
+const kNotificationId = "fxmonitor";
+const kRemoteSettingsKey = "fxmonitor-breaches";
+
+async function fxmonitorNotificationShown() {
+ await TestUtils.waitForCondition(() => {
+ return (
+ PopupNotifications.getNotification(kNotificationId) &&
+ PopupNotifications.panel.state == "open"
+ );
+ }, "Waiting for fxmonitor notification to be shown");
+ ok(true, "Firefox Monitor PopupNotification was added.");
+}
+
+async function fxmonitorNotificationGone() {
+ await TestUtils.waitForCondition(() => {
+ return (
+ !PopupNotifications.getNotification(kNotificationId) &&
+ PopupNotifications.panel.state == "closed"
+ );
+ }, "Waiting for fxmonitor notification to go away");
+ ok(true, "Firefox Monitor PopupNotification was removed.");
+}
+
+let cps2 = Cc["@mozilla.org/content-pref/service;1"].getService(
+ Ci.nsIContentPrefService2
+);
+
+async function clearWarnedHosts() {
+ return new Promise((resolve, reject) => {
+ cps2.removeByName(
+ "extensions.fxmonitor.hostAlreadyWarned",
+ Cu.createLoadContext(),
+ {
+ handleCompletion: resolve,
+ }
+ );
+ });
+}
+
+add_task(async function test_main_flow() {
+ info("Test that we show the first alert correctly for a recent breach.");
+
+ // Pre-populate the Remote Settings collection with a breach.
+ let db = await RemoteSettings(kRemoteSettingsKey).db;
+ let BreachDate = new Date();
+ let AddedDate = new Date();
+ await db.create({
+ Domain: "example.com",
+ Name: "Example Site",
+ BreachDate: `${BreachDate.getFullYear()}-${BreachDate.getMonth() +
+ 1}-${BreachDate.getDate()}`,
+ AddedDate: `${AddedDate.getFullYear()}-${AddedDate.getMonth() +
+ 1}-${AddedDate.getDate()}`,
+ PwnCount: 1000000,
+ });
+ await db.importChanges({}, 1234567);
+
+ // Trigger a sync.
+ await RemoteSettings(kRemoteSettingsKey).emit("sync", {
+ data: {
+ current: await RemoteSettings(kRemoteSettingsKey).get(),
+ },
+ });
+
+ // Enable the extension.
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.fxmonitor.FirefoxMonitorURL", "http://example.org"]],
+ });
+
+ // Open a tab and wait for the alert.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "http://example.com"
+ );
+ await fxmonitorNotificationShown();
+
+ // Test that dismissing works.
+ let notification = Array.prototype.find.call(
+ PopupNotifications.panel.children,
+ elt => elt.getAttribute("popupid") == kNotificationId
+ );
+ EventUtils.synthesizeMouseAtCenter(notification.secondaryButton, {});
+ await fxmonitorNotificationGone();
+
+ // Reload and make sure the alert isn't shown again.
+ let promise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ tab.linkedBrowser.reload();
+ await promise;
+ await fxmonitorNotificationGone();
+
+ // Reset state.
+ await db.clear();
+ await db.importChanges({}, 1234567);
+ await clearWarnedHosts();
+ await SpecialPowers.pushPrefEnv({
+ clear: [["extensions.fxmonitor.firstAlertShown"]],
+ });
+
+ // Reload and wait for the alert.
+ promise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ tab.linkedBrowser.reload();
+ await promise;
+ await fxmonitorNotificationShown();
+
+ // Test that the primary button opens Firefox Monitor in a new tab.
+ notification = Array.prototype.find.call(
+ PopupNotifications.panel.children,
+ elt => elt.getAttribute("popupid") == kNotificationId
+ );
+ let url = `http://example.org/?breach=${encodeURIComponent(
+ "Example Site"
+ )}&utm_source=firefox&utm_medium=popup`;
+ promise = BrowserTestUtils.waitForNewTab(gBrowser, url);
+ EventUtils.synthesizeMouseAtCenter(notification.button, {});
+ let newtab = await promise;
+
+ // Close the new tab and check that the alert is gone.
+ BrowserTestUtils.removeTab(newtab);
+ await fxmonitorNotificationGone();
+
+ // Reset state (but not firstAlertShown).
+ await db.clear();
+ await db.importChanges({}, 1234567);
+ await clearWarnedHosts();
+
+ info(
+ "Test that we do not show the second alert for a breach added over two months ago."
+ );
+
+ // Add a new "old" breach - added over 2 months ago.
+ AddedDate.setMonth(AddedDate.getMonth() - 3);
+ await db.create({
+ Domain: "example.com",
+ Name: "Example Site",
+ BreachDate: `${BreachDate.getFullYear()}-${BreachDate.getMonth() +
+ 1}-${BreachDate.getDate()}`,
+ AddedDate: `${AddedDate.getFullYear()}-${AddedDate.getMonth() +
+ 1}-${AddedDate.getDate()}`,
+ PwnCount: 1000000,
+ });
+ await db.importChanges({}, 1234567);
+
+ // Trigger a sync.
+ await RemoteSettings(kRemoteSettingsKey).emit("sync", {
+ data: {
+ current: await RemoteSettings(kRemoteSettingsKey).get(),
+ },
+ });
+
+ // Check that there's no alert for the old breach.
+ promise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ tab.linkedBrowser.reload();
+ await promise;
+ await fxmonitorNotificationGone();
+
+ // Reset state (but not firstAlertShown).
+ AddedDate.setMonth(AddedDate.getMonth() + 3);
+ await db.clear();
+ await db.importChanges({}, 1234567);
+ await clearWarnedHosts();
+
+ info("Test that we do show the second alert for a recent breach.");
+
+ // Add a new "recent" breach.
+ await db.create({
+ Domain: "example.com",
+ Name: "Example Site",
+ BreachDate: `${BreachDate.getFullYear()}-${BreachDate.getMonth() +
+ 1}-${BreachDate.getDate()}`,
+ AddedDate: `${AddedDate.getFullYear()}-${AddedDate.getMonth() +
+ 1}-${AddedDate.getDate()}`,
+ PwnCount: 1000000,
+ });
+ await db.importChanges({}, 1234567);
+
+ // Trigger a sync.
+ await RemoteSettings(kRemoteSettingsKey).emit("sync", {
+ data: {
+ current: await RemoteSettings(kRemoteSettingsKey).get(),
+ },
+ });
+
+ // Check that there's an alert for the new breach.
+ promise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ tab.linkedBrowser.reload();
+ await promise;
+ await fxmonitorNotificationShown();
+
+ // Reset state (including firstAlertShown)
+ await db.clear();
+ await db.importChanges({}, 1234567);
+ await clearWarnedHosts();
+ await SpecialPowers.pushPrefEnv({
+ clear: [["extensions.fxmonitor.firstAlertShown"]],
+ });
+
+ info(
+ "Test that we do not show the first alert for a breach added over a year ago."
+ );
+
+ // Add a new "old" breach - added over a year ago.
+ AddedDate.setFullYear(AddedDate.getFullYear() - 2);
+ await db.create({
+ Domain: "example.com",
+ Name: "Example Site",
+ BreachDate: `${BreachDate.getFullYear()}-${BreachDate.getMonth() +
+ 1}-${BreachDate.getDate()}`,
+ AddedDate: `${AddedDate.getFullYear()}-${AddedDate.getMonth() +
+ 1}-${AddedDate.getDate()}`,
+ PwnCount: 1000000,
+ });
+ await db.importChanges({}, 1234567);
+
+ // Trigger a sync.
+ await RemoteSettings(kRemoteSettingsKey).emit("sync", {
+ data: {
+ current: await RemoteSettings(kRemoteSettingsKey).get(),
+ },
+ });
+
+ // Check that there's no alert for the old breach.
+ promise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ tab.linkedBrowser.reload();
+ await promise;
+ await fxmonitorNotificationGone();
+
+ // Clean up.
+ BrowserTestUtils.removeTab(tab);
+ await db.clear();
+ await db.importChanges({}, 1234567);
+ // Trigger a sync to clear.
+ await RemoteSettings(kRemoteSettingsKey).emit("sync", {
+ data: {
+ current: [],
+ },
+ });
+ await clearWarnedHosts();
+ await SpecialPowers.pushPrefEnv({
+ clear: [["extensions.fxmonitor.firstAlertShown"]],
+ });
+});