mirror of
https://github.com/mozilla/gecko-dev.git
synced 2025-11-11 13:48:23 +02:00
Differential Revision: https://phabricator.services.mozilla.com/D28990 --HG-- extra : moz-landing-system : lando
585 lines
18 KiB
JavaScript
585 lines
18 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/. */
|
|
|
|
/* globals ExtensionAPI, XPCOMUtils */
|
|
|
|
ChromeUtils.defineModuleGetter(this, "EveryWindow",
|
|
"resource:///modules/EveryWindow.jsm");
|
|
ChromeUtils.defineModuleGetter(this, "PluralForm",
|
|
"resource://gre/modules/PluralForm.jsm");
|
|
ChromeUtils.defineModuleGetter(this, "Preferences",
|
|
"resource://gre/modules/Preferences.jsm");
|
|
ChromeUtils.defineModuleGetter(this, "RemoteSettings",
|
|
"resource://services-settings/remote-settings.js");
|
|
ChromeUtils.defineModuleGetter(this, "Services",
|
|
"resource://gre/modules/Services.jsm");
|
|
|
|
const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
|
|
|
|
this.fxmonitor = class extends ExtensionAPI {
|
|
getAPI(context) {
|
|
return {
|
|
fxmonitor: {
|
|
async start() {
|
|
await FirefoxMonitor.init(context.extension);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
onShutdown(shutdownReason) {
|
|
if (Services.startup.shuttingDown) {
|
|
return;
|
|
}
|
|
|
|
FirefoxMonitor.stopObserving();
|
|
}
|
|
};
|
|
|
|
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",
|
|
|
|
// Telemetry event recording is enabled by default.
|
|
// If this pref exists and is true-y, it's disabled.
|
|
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);
|
|
},
|
|
|
|
getURL(aPath) {
|
|
return this.extension.getURL(aPath);
|
|
},
|
|
|
|
getString(aKey) {
|
|
return this.strings.GetStringFromName(aKey);
|
|
},
|
|
|
|
getFormattedString(aKey, args) {
|
|
return this.strings.formatStringFromName(aKey, args, args.length);
|
|
},
|
|
|
|
// 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(aExtension) {
|
|
this.extension = aExtension;
|
|
|
|
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);
|
|
|
|
await this.loadStrings();
|
|
await this.loadBreaches();
|
|
},
|
|
|
|
loadStrings() {
|
|
let l10nManifest;
|
|
if (this.extension.rootURI instanceof Ci.nsIJARURI) {
|
|
l10nManifest = this.extension.rootURI.JARFile
|
|
.QueryInterface(Ci.nsIFileURL).file;
|
|
} else if (this.extension.rootURI instanceof Ci.nsIFileURL) {
|
|
l10nManifest = this.extension.rootURI.file;
|
|
}
|
|
|
|
if (l10nManifest) {
|
|
XPCOMUtils.defineLazyGetter(this, "strings", () => {
|
|
Components.manager.addBootstrappedManifestLocation(l10nManifest);
|
|
|
|
return Services.strings.createBundle(
|
|
"chrome://fxmonitor/locale/fxmonitor.properties");
|
|
});
|
|
} else {
|
|
// Something is very strange if we reach this line, so we throw
|
|
// in order to prevent init from completing and burst the stack.
|
|
throw new Error("Cannot find fxmonitor chrome.manifest for registering translated strings");
|
|
}
|
|
},
|
|
|
|
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(this.getURL("privileged/FirefoxMonitor.css"),
|
|
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(this.getURL("privileged/FirefoxMonitor.css"),
|
|
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.createElementNS(XUL_NS, "box");
|
|
anchorBox.setAttribute("id", `${this.kNotificationID}-notification-anchor`);
|
|
anchorBox.classList.add("notification-anchor-icon");
|
|
let img = doc.createElementNS(XUL_NS, "image");
|
|
img.setAttribute("role", "button");
|
|
img.classList.add(`${this.kNotificationID}-icon`);
|
|
img.style.listStyleImage = `url(${this.getURL("assets/monitor32.svg")})`;
|
|
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.createElementNS(XUL_NS, "popupnotification");
|
|
let pnContent = doc.createElementNS(XUL_NS, "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));
|
|
Services.telemetry.recordEvent("fxmonitor", "interaction", "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: this.getURL("assets/monitor32.svg"),
|
|
}
|
|
);
|
|
|
|
Services.telemetry.recordEvent("fxmonitor", "interaction", "doorhanger_shown");
|
|
|
|
this.notificationsByWindow.get(win).add(n);
|
|
},
|
|
};
|
|
|
|
function PanelUI(doc) {
|
|
this.site = null;
|
|
this.doc = doc;
|
|
|
|
let box = doc.createElementNS(XUL_NS, "vbox");
|
|
|
|
let elt = doc.createElementNS(XUL_NS, "description");
|
|
elt.textContent = this.getString("fxmonitor.popupHeader");
|
|
elt.classList.add("headerText");
|
|
box.appendChild(elt);
|
|
|
|
elt = doc.createElementNS(XUL_NS, "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", { });
|
|
|
|
Services.telemetry.recordEvent("fxmonitor", "interaction", "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: () => {
|
|
Services.telemetry.recordEvent("fxmonitor", "interaction", "dismiss_btn");
|
|
},
|
|
}, {
|
|
label: this.getFormattedString("fxmonitor.neverShowButton.label", [this.brandString]),
|
|
accessKey: this.getString("fxmonitor.neverShowButton.accessKey"),
|
|
callback: () => {
|
|
FirefoxMonitor.disable();
|
|
Services.telemetry.recordEvent("fxmonitor", "interaction", "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);
|
|
},
|
|
};
|