diff options
Diffstat (limited to 'toolkit/mozapps/extensions/AbuseReporter.sys.mjs')
-rw-r--r-- | toolkit/mozapps/extensions/AbuseReporter.sys.mjs | 587 |
1 files changed, 4 insertions, 583 deletions
diff --git a/toolkit/mozapps/extensions/AbuseReporter.sys.mjs b/toolkit/mozapps/extensions/AbuseReporter.sys.mjs index 944cef507c..966e2a6dd5 100644 --- a/toolkit/mozapps/extensions/AbuseReporter.sys.mjs +++ b/toolkit/mozapps/extensions/AbuseReporter.sys.mjs @@ -2,310 +2,39 @@ * 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/. */ -import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; - import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; -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 = [ +const AMO_SUPPORTED_ADDON_TYPES = [ "extension", "theme", "sitepermission", // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. "sitepermission-deprecated", + "dictionary", ]; -// An expanded set of addon types supported when the abuse report hosted on AMO is enabled -// (based on the "extensions.abuseReport.amoFormEnabled" pref). -const AMO_SUPPORTED_ADDON_TYPES = [...SUPPORTED_ADDON_TYPES, "dictionary"]; - const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { - AMTelemetry: "resource://gre/modules/AddonManager.sys.mjs", AddonManager: "resource://gre/modules/AddonManager.sys.mjs", ClientID: "resource://gre/modules/ClientID.sys.mjs", }); -XPCOMUtils.defineLazyPreferenceGetter( - lazy, - "ABUSE_REPORT_URL", - PREF_ABUSE_REPORT_URL -); - -XPCOMUtils.defineLazyPreferenceGetter( - lazy, - "AMO_DETAILS_API_URL", - PREF_AMO_DETAILS_API_URL -); - -// Whether the abuse report feature should open a form hosted by the url -// derived from the one set on the extensions.abuseReport.amoFormURL pref -// or use the abuse report panel integrated in Firefox. -XPCOMUtils.defineLazyPreferenceGetter( - lazy, - "ABUSE_REPORT_AMO_FORM_ENABLED", - "extensions.abuseReport.amoFormEnabled", - true -); - -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", -]); - -export 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(() => ""), - }); -} - /** - * A singleton object used to create new AbuseReport instances for a given addonId - * and enforce a minium amount of time between two report submissions . + * A singleton used to manage abuse reports for add-ons. */ export const AbuseReporter = { - _lastReportTimestamp: null, - - get amoFormEnabled() { - return lazy.ABUSE_REPORT_AMO_FORM_ENABLED; - }, - getAMOFormURL({ addonId }) { return Services.urlFormatter .formatURLPref("extensions.abuseReport.amoFormURL") .replace(/%addonID%/g, addonId); }, - // 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; - }, - isSupportedAddonType(addonType) { - if (this.amoFormEnabled) { - return AMO_SUPPORTED_ADDON_TYPES.includes(addonType); - } - return SUPPORTED_ADDON_TYPES.includes(addonType); - }, - - /** - * 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 lazy.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) { - lazy.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: - // https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#detail - details = await fetch(`${lazy.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( - () => 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); - - lazy.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 - // (https://addons-server.readthedocs.io/en/latest/topics/api/overview.html#api-overview-translations) - 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: lazy.AddonManager.SIGNEDSTATE_UNKNOWN, - // Set the installTelemetryInfo.source to "not_installed". - installTelemetryInfo: { source: "not_installed" }, - }; + return AMO_SUPPORTED_ADDON_TYPES.includes(addonType); }, /** @@ -392,312 +121,4 @@ export const AbuseReporter = { 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); - }, - - /** - * 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, - "chrome://mozapps/content/extensions/abuse-report-frame.html", - 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, 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; - lazy.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(() => 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(lazy.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; - } -} |