Source code

Revision control

Other Tools

/* 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/. */
const EXPORTED_SYMBOLS = ["AbuseReporter", "AbuseReportError"];
const { XPCOMUtils } = ChromeUtils.import(
);
Cu.importGlobalProperties(["fetch"]);
const PREF_ABUSE_REPORT_URL = "extensions.abuseReport.url";
const PREF_AMO_DETAILS_API_URL = "extensions.abuseReport.amoDetailsURL";
// Name associated with the report dialog window.
const DIALOG_WINDOW_NAME = "addons-abuse-report-dialog";
// Maximum length of the string properties sent to the API endpoint.
const MAX_STRING_LENGTH = 255;
// Minimum time between report submissions (in ms).
const MIN_MS_BETWEEN_SUBMITS = 30000;
// The addon types currently supported by the integrated abuse report panel.
const SUPPORTED_ADDON_TYPES = ["extension", "theme"];
XPCOMUtils.defineLazyModuleGetters(this, {
});
XPCOMUtils.defineLazyPreferenceGetter(
this,
"ABUSE_REPORT_URL",
PREF_ABUSE_REPORT_URL
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"AMO_DETAILS_API_URL",
PREF_AMO_DETAILS_API_URL
);
const PRIVATE_REPORT_PROPS = Symbol("privateReportProps");
const ERROR_TYPES = Object.freeze([
"ERROR_ABORTED_SUBMIT",
"ERROR_ADDON_NOTFOUND",
"ERROR_CLIENT",
"ERROR_NETWORK",
"ERROR_UNKNOWN",
"ERROR_RECENT_SUBMIT",
"ERROR_SERVER",
"ERROR_AMODETAILS_NOTFOUND",
"ERROR_AMODETAILS_FAILURE",
]);
class AbuseReportError extends Error {
constructor(errorType, errorInfo = undefined) {
if (!ERROR_TYPES.includes(errorType)) {
throw new Error(`Unknown AbuseReportError type "${errorType}"`);
}
let message = errorInfo ? `${errorType} - ${errorInfo}` : errorType;
super(message);
this.name = "AbuseReportError";
this.errorType = errorType;
this.errorInfo = errorInfo;
}
}
/**
* Create an error info string from a fetch response object.
*
* @param {Response} response
* A fetch response object to convert into an errorInfo string.
*
* @returns {Promise<string>}
* The errorInfo string to be included in an AbuseReportError.
*/
async function responseToErrorInfo(response) {
return JSON.stringify({
status: response.status,
responseText: await response.text().catch(err => ""),
});
}
/**
* A singleton object used to create new AbuseReport instances for a given addonId
* and enforce a minium amount of time between two report submissions .
*/
const AbuseReporter = {
_lastReportTimestamp: null,
// Error types.
updateLastReportTimestamp() {
this._lastReportTimestamp = Date.now();
},
getTimeFromLastReport() {
const currentTimestamp = Date.now();
if (this._lastReportTimestamp > currentTimestamp) {
// Reset the last report timestamp if it is in the future.
this._lastReportTimestamp = null;
}
if (!this._lastReportTimestamp) {
return Infinity;
}
return currentTimestamp - this._lastReportTimestamp;
},
/**
* Create an AbuseReport instance, given the addonId and a reportEntryPoint.
*
* @param {string} addonId
* The id of the addon to create the report instance for.
* @param {object} options
* @param {string} options.reportEntryPoint
* An identifier that represent the entry point for the report flow.
*
* @returns {Promise<AbuseReport>}
* Returns a promise that resolves to an instance of the AbuseReport
* class, which represent an ongoing report.
*/
async createAbuseReport(addonId, { reportEntryPoint } = {}) {
let addon = await AddonManager.getAddonByID(addonId);
if (!addon) {
// The addon isn't installed, query the details from the AMO API endpoint.
addon = await this.queryAMOAddonDetails(addonId, reportEntryPoint);
}
if (!addon) {
AMTelemetry.recordReportEvent({
addonId,
errorType: "ERROR_ADDON_NOTFOUND",
reportEntryPoint,
});
throw new AbuseReportError("ERROR_ADDON_NOTFOUND");
}
const reportData = await this.getReportData(addon);
return new AbuseReport({
addon,
reportData,
reportEntryPoint,
});
},
/**
* Retrieves the addon details from the AMO API endpoint, used to create
* abuse reports on non-installed addon-ons.
*
* For the addon details that may be translated (e.g. addon name, description etc.)
* the function will try to retrieve the string localized in the same locale used
* by Gecko (and fallback to "en-US" if that locale is unavailable).
*
* The addon creator properties are set to the first author available.
*
* @param {string} addonId
* The id of the addon to retrieve the details available on AMO.
* @param {string} reportEntryPoint
* The entry point for the report flow (to be included in the telemetry
* recorded in case of failures).
*
* @returns {Promise<AMOAddonDetails|null>}
* Returns a promise that resolves to an AMOAddonDetails object,
* which has the subset of the AddonWrapper properties which are
* needed by the abuse report panel or the report data sent to
* the abuse report API endpoint), or null if it fails to
* retrieve the details from AMO.
*
* @typedef {object} AMOAddonDetails
* @prop {string} id
* @prop {string} name
* @prop {string} version
* @prop {string} description
* @prop {string} type
* @prop {string} iconURL
* @prop {string} homepageURL
* @prop {string} supportURL
* @prop {AMOAddonCreator} creator
* @prop {boolean} isRecommended
* @prop {number} signedState=AddonManager.SIGNEDSTATE_UNKNOWN
* @prop {object} installTelemetryInfo={ source: "not_installed" }
*
* @typedef {object} AMOAddonCreator
* @prop {string} name
* @prop {string} url
*/
async queryAMOAddonDetails(addonId, reportEntryPoint) {
let details;
try {
// This should be the API endpoint documented at:
details = await fetch(`${AMO_DETAILS_API_URL}/${addonId}`, {
credentials: "omit",
referrerPolicy: "no-referrer",
headers: { "Content-Type": "application/json" },
}).then(async response => {
if (response.status === 200) {
return response.json();
}
let errorInfo = await responseToErrorInfo(response).catch(
err => undefined
);
if (response.status === 404) {
// Record a different telemetry event for 404 errors.
throw new AbuseReportError("ERROR_AMODETAILS_NOTFOUND", errorInfo);
}
throw new AbuseReportError("ERROR_AMODETAILS_FAILURE", errorInfo);
});
} catch (err) {
// Log the original error in the browser console.
Cu.reportError(err);
AMTelemetry.recordReportEvent({
addonId,
errorType: err.errorType || "ERROR_AMODETAILS_FAILURE",
reportEntryPoint,
});
return null;
}
const locale = Services.locale.appLocaleAsBCP47;
// Get a string value from a translated value
const getTranslatedValue = value => {
if (typeof value === "string") {
return value;
}
return value && (value[locale] || value["en-US"]);
};
const getAuthorField = fieldName =>
details.authors && details.authors[0] && details.authors[0][fieldName];
// Normalize type "statictheme" (which is the type used on the AMO API side)
// into "theme" (because it is the type we use and expect on the Firefox side
// for this addon type).
const addonType = details.type === "statictheme" ? "theme" : details.type;
return {
id: addonId,
name: getTranslatedValue(details.name),
version: details.current_version.version,
description: getTranslatedValue(details.summary),
type: addonType,
iconURL: details.icon_url,
homepageURL: getTranslatedValue(details.homepage),
supportURL: getTranslatedValue(details.support_url),
// Set the addon creator to the first author in the AMO details.
creator: {
name: getAuthorField("name"),
url: getAuthorField("url"),
},
isRecommended: details.is_recommended,
// Set signed state to unknown because it isn't installed.
signedState: AddonManager.SIGNEDSTATE_UNKNOWN,
// Set the installTelemetryInfo.source to "not_installed".
installTelemetryInfo: { source: "not_installed" },
};
},
/**
* Helper function that retrieves from an addon object all the data to send
* as part of the submission request, besides the `reason`, `message` which are
* going to be received from the submit method of the report object returned
* by `createAbuseReport`.
*
* @param {AddonWrapper} addon
* The addon object to collect the detail from.
*
* @return {object}
* An object that contains the collected details.
*/
async getReportData(addon) {
const truncateString = text =>
typeof text == "string" ? text.slice(0, MAX_STRING_LENGTH) : text;
// Normalize addon_install_source and addon_install_method values
// as expected by the server API endpoint. Returns null if the
// value is not a string.
const normalizeValue = text =>
typeof text == "string"
? text.toLowerCase().replace(/[- :]/g, "_")
: null;
const installInfo = addon.installTelemetryInfo || {};
const data = {
addon: addon.id,
addon_version: addon.version,
addon_name: truncateString(addon.name),
addon_summary: truncateString(addon.description),
addon_install_origin:
addon.sourceURI && truncateString(addon.sourceURI.spec),
install_date: addon.installDate && addon.installDate.toISOString(),
addon_install_source: normalizeValue(installInfo.source),
addon_install_source_url:
installInfo.sourceURL && truncateString(installInfo.sourceURL),
addon_install_method: normalizeValue(installInfo.method),
};
switch (addon.signedState) {
case AddonManager.SIGNEDSTATE_BROKEN:
data.addon_signature = "broken";
break;
case AddonManager.SIGNEDSTATE_UNKNOWN:
data.addon_signature = "unknown";
break;
case AddonManager.SIGNEDSTATE_MISSING:
data.addon_signature = "missing";
break;
case AddonManager.SIGNEDSTATE_PRELIMINARY:
data.addon_signature = "preliminary";
break;
case AddonManager.SIGNEDSTATE_SIGNED:
data.addon_signature = "signed";
break;
case AddonManager.SIGNEDSTATE_SYSTEM:
data.addon_signature = "system";
break;
case AddonManager.SIGNEDSTATE_PRIVILEGED:
data.addon_signature = "privileged";
break;
default:
data.addon_signature = `unknown: ${addon.signedState}`;
}
// Set "curated" as addon_signature on recommended addons
// (addon.isRecommended internally checks that the addon is also
// signed correctly).
if (addon.isRecommended) {
data.addon_signature = "curated";
}
data.client_id = await ClientID.getClientIdHash();
data.app = Services.appinfo.name.toLowerCase();
data.appversion = Services.appinfo.version;
data.lang = Services.locale.appLocaleAsBCP47;
data.operating_system = AppConstants.platform;
data.operating_system_version = Services.sysinfo.getProperty("version");
return data;
},
/**
* Helper function that returns a reference to a report dialog window
* already opened (if any).
*
* @returns {Window?}
*/
getOpenDialog() {
return Services.ww.getWindowByName(DIALOG_WINDOW_NAME, null);
},
/**
* Helper function that opens an abuse report form in a new dialog window.
*
* @param {string} addonId
* The addonId being reported.
* @param {string} reportEntryPoint
* The entry point from which the user has triggered the abuse report
* flow.
* @param {XULElement} browser
* The browser element (if any) that is opening the report window.
*
* @return {Promise<AbuseReportDialog>}
* Returns an AbuseReportDialog object, rejects if it fails to open
* the dialog.
*
* @typedef {object} AbuseReportDialog
* An object that represents the abuse report dialog.
* @prop {function} close
* A method that closes the report dialog (used by the caller
* to close the dialog when the user chooses to close the window
* that started the abuse report flow).
* @prop {Promise<AbuseReport|undefined} promiseReport
* A promise resolved to an AbuseReport instance if the report should
* be submitted, or undefined if the user has cancelled the report.
* Rejects if it fails to create an AbuseReport instance or to open
* the abuse report window.
*/
async openDialog(addonId, reportEntryPoint, browser) {
const chromeWin = browser && browser.ownerGlobal;
if (!chromeWin) {
throw new Error("Abuse Reporter dialog cancelled, opener tab closed");
}
const dialogWin = this.getOpenDialog();
if (dialogWin) {
// If an abuse report dialog is already open, cancel the
// previous report flow and start a new one.
const {
deferredReport,
promiseReport,
} = dialogWin.arguments[0].wrappedJSObject;
deferredReport.resolve({ userCancelled: true });
await promiseReport;
}
const report = await AbuseReporter.createAbuseReport(addonId, {
reportEntryPoint,
});
if (!SUPPORTED_ADDON_TYPES.includes(report.addon.type)) {
throw new Error(
`Addon type "${report.addon.type}" is not currently supported by the integrated abuse reporting feature`
);
}
const params = Cc["@mozilla.org/array;1"].createInstance(
Ci.nsIMutableArray
);
const dialogInit = {
report,
openWebLink(url) {
chromeWin.openWebLinkIn(url, "tab", {
relatedToCurrent: true,
});
},
};
params.appendElement(dialogInit);
let win;
function closeDialog() {
if (win && !win.closed) {
win.close();
}
}
const promiseReport = new Promise((resolve, reject) => {
dialogInit.deferredReport = { resolve, reject };
}).then(
({ userCancelled }) => {
closeDialog();
return userCancelled ? undefined : report;
},
err => {
Cu.reportError(
`Unexpected abuse report panel error: ${err} :: ${err.stack}`
);
closeDialog();
return Promise.reject({
message: "Unexpected abuse report panel error",
});
}
);
const promiseReportPanel = new Promise((resolve, reject) => {
dialogInit.deferredReportPanel = { resolve, reject };
});
dialogInit.promiseReport = promiseReport;
dialogInit.promiseReportPanel = promiseReportPanel;
win = Services.ww.openWindow(
chromeWin,
DIALOG_WINDOW_NAME,
// Set the dialog window options (including a reasonable initial
// window height size, eventually adjusted by the panel once it
// has been rendered its content).
"dialog,centerscreen,height=700",
params
);
return {
close: closeDialog,
promiseReport,
// Properties used in tests
promiseReportPanel,
window: win,
};
},
};
/**
* Represents an ongoing abuse report. Instances of this class are created
* by the `AbuseReporter.createAbuseReport` method.
*
* This object is used by the reporting UI panel and message bars to:
*
* - get an errorType in case of a report creation error (e.g. because of a
* previously submitted report)
* - get the addon details used inside the reporting panel
* - submit the abuse report (and re-submit if a previous submission failed
* and the user choose to retry to submit it again)
* - abort an ongoing submission
*
* @param {object} options
* @param {AddonWrapper|null} options.addon
* AddonWrapper instance for the extension/theme being reported.
* (May be null if the extension has not been found).
* @param {object|null} options.reportData
* An object which contains addon and environment details to send as part of a submission
* (may be null if the report has a createErrorType).
* @param {string} options.reportEntryPoint
* A string that identify how the report has been triggered.
*/
class AbuseReport {
constructor({ addon, createErrorType, reportData, reportEntryPoint }) {
this[PRIVATE_REPORT_PROPS] = {
aborted: false,
abortController: new AbortController(),
addon,
reportData,
reportEntryPoint,
// message and reason are initially null, and then set by the panel
// using the related set method.
message: null,
reason: null,
};
}
recordTelemetry(errorType) {
const { addon, reportEntryPoint } = this;
AMTelemetry.recordReportEvent({
addonId: addon.id,
addonType: addon.type,
errorType,
reportEntryPoint,
});
}
/**
* Submit the current report, given a reason and a message.
*
* @returns {Promise<void>}
* Resolves once the report has been successfully submitted.
* It rejects with an AbuseReportError if the report couldn't be
* submitted for a known reason (or another Error type otherwise).
*/
async submit() {
const {
aborted,
abortController,
message,
reason,
reportData,
reportEntryPoint,
} = this[PRIVATE_REPORT_PROPS];
// Record telemetry event and throw an AbuseReportError.
const rejectReportError = async (errorType, { response } = {}) => {
this.recordTelemetry(errorType);
// Leave errorInfo empty if there is no response or fails to
// be converted into an error info object.
const errorInfo = response
? await responseToErrorInfo(response).catch(err => undefined)
: undefined;
throw new AbuseReportError(errorType, errorInfo);
};
if (aborted) {
// Report aborted before being actually submitted.
return rejectReportError("ERROR_ABORTED_SUBMIT");
}
// Prevent submit of a new abuse report in less than MIN_MS_BETWEEN_SUBMITS.
let msFromLastReport = AbuseReporter.getTimeFromLastReport();
if (msFromLastReport < MIN_MS_BETWEEN_SUBMITS) {
return rejectReportError("ERROR_RECENT_SUBMIT");
}
let response;
try {
response = await fetch(ABUSE_REPORT_URL, {
signal: abortController.signal,
method: "POST",
credentials: "omit",
referrerPolicy: "no-referrer",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...reportData,
report_entry_point: reportEntryPoint,
message,
reason,
}),
});
} catch (err) {
if (err.name === "AbortError") {
return rejectReportError("ERROR_ABORTED_SUBMIT");
}
Cu.reportError(err);
return rejectReportError("ERROR_NETWORK");
}
if (response.ok && response.status >= 200 && response.status < 400) {
// Ensure that the response is also a valid json format.
try {
await response.json();
} catch (err) {
this.recordTelemetry("ERROR_UNKNOWN");
throw err;
}
AbuseReporter.updateLastReportTimestamp();
this.recordTelemetry();
return undefined;
}
if (response.status >= 400 && response.status < 500) {
return rejectReportError("ERROR_CLIENT", { response });
}
if (response.status >= 500 && response.status < 600) {
return rejectReportError("ERROR_SERVER", { response });
}
// We got an unexpected HTTP status code.
return rejectReportError("ERROR_UNKNOWN", { response });
}
/**
* Abort the report submission.
*/
abort() {
const { abortController } = this[PRIVATE_REPORT_PROPS];
abortController.abort();
this[PRIVATE_REPORT_PROPS].aborted = true;
}
get addon() {
return this[PRIVATE_REPORT_PROPS].addon;
}
get reportEntryPoint() {
return this[PRIVATE_REPORT_PROPS].reportEntryPoint;
}
/**
* Set the open message (called from the panel when the user submit the report)
*
* @parm {string} message
* An optional string which contains a description for the reported issue.
*/
setMessage(message) {
this[PRIVATE_REPORT_PROPS].message = message;
}
/**
* Set the report reason (called from the panel when the user submit the report)
*
* @parm {string} reason
* String identifier for the report reason.
*/
setReason(reason) {
this[PRIVATE_REPORT_PROPS].reason = reason;
}
}