/* 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/. */ /* eslint no-shadow: error, mozilla/no-aArgs: error */ /** * @typedef {import("./AddonSearchEngine.sys.mjs").AddonSearchEngine} AddonSearchEngine * @typedef {import("./OpenSearchEngine.sys.mjs").OpenSearchEngine} OpenSearchEngine */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { SearchSettings: "moz-src:///toolkit/components/search/SearchSettings.sys.mjs", SearchUtils: "moz-src:///toolkit/components/search/SearchUtils.sys.mjs", OpenSearchEngine: "moz-src:///toolkit/components/search/OpenSearchEngine.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "logConsole", () => { return console.createInstance({ prefix: "SearchEngine", maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn", }); }); // Supported OpenSearch parameters // See https://web.archive.org/web/20060203040832/http://opensearch.a9.com/spec/1.1/querysyntax/#core const OS_PARAM_INPUT_ENCODING = "inputEncoding"; const OS_PARAM_LANGUAGE = "language"; const OS_PARAM_OUTPUT_ENCODING = "outputEncoding"; // Default values const OS_PARAM_LANGUAGE_DEF = "*"; const OS_PARAM_OUTPUT_ENCODING_DEF = "UTF-8"; // "Unsupported" OpenSearch parameters. For example, we don't support // page-based results, so if the engine requires that we send the "page index" // parameter, we'll always send "1". const OS_PARAM_COUNT = "count"; const OS_PARAM_START_INDEX = "startIndex"; const OS_PARAM_START_PAGE = "startPage"; // Default values const OS_PARAM_COUNT_DEF = "20"; // 20 results const OS_PARAM_START_INDEX_DEF = "1"; // start at 1st result const OS_PARAM_START_PAGE_DEF = "1"; // 1st page // A array of arrays containing parameters that we don't fully support, and // their default values. We will only send values for these parameters if // required, since our values are just really arbitrary "guesses" that should // give us the output we want. var OS_UNSUPPORTED_PARAMS = [ [OS_PARAM_COUNT, OS_PARAM_COUNT_DEF], [OS_PARAM_START_INDEX, OS_PARAM_START_INDEX_DEF], [OS_PARAM_START_PAGE, OS_PARAM_START_PAGE_DEF], ]; // An array of attributes that are saved in the engines `_metaData` object. // Attributes not in this array are considered as system attributes. const USER_ATTRIBUTES = ["alias", "order", "hideOneOffButton"]; /** * Truncates big blobs of (data-)URIs to console-friendly sizes * * @param {string} str * String to tone down * @param {number} len * Maximum length of the string to return. Defaults to the length of a tweet. * @returns {string} * The shortend string. */ function limitURILength(str, len = 140) { if (str.length > len) { return str.slice(0, len) + "..."; } return str; } /** * Represents a name/value pair for a parameter */ export class QueryParameter { /** * @param {string} name * The parameter's name. Must not be null. * @param {string} value * The value of the parameter. May be an empty string, must not be null or * undefined. */ constructor(name, value) { if (!name || value == null) { throw Components.Exception( "missing name or value for QueryParameter!", Cr.NS_ERROR_INVALID_ARG ); } this.name = name; this._value = value; } get value() { return this._value; } /** * Creates a JavaScript object that represents this parameter. * * @returns {object} * An object suitable for serialization as JSON. */ toJSON() { return { name: this.name, value: this.value, }; } } /** * Perform OpenSearch parameter substitution on a parameter value. * * @see https://web.archive.org/web/20060203040832/http://opensearch.a9.com/spec/1.1/querysyntax/#core * * @param {string} paramValue * The OpenSearch search parameters. * @param {string} searchTerms * The user-provided search terms. This string will inserted into * paramValue as the value of the searchTerms parameter. * This value must already be escaped appropriately - it is inserted * as-is. * @param {string} queryCharset * The character set of the search engine to use for query encoding. * @returns {string} * An updated parameter string. */ function paramSubstitution(paramValue, searchTerms, queryCharset) { const PARAM_REGEXP = /\{(\w+)(\??)\}/g; return paramValue.replace(PARAM_REGEXP, function (match, name, optional) { // {searchTerms} is by far the most common param so handle it first. if (name == "searchTerms") { return searchTerms; } // {inputEncoding} is the second most common param. if (name == OS_PARAM_INPUT_ENCODING) { return queryCharset; } // Handle the less common OpenSearch parameters we're confident about. if (name == OS_PARAM_LANGUAGE) { return Services.locale.requestedLocale || OS_PARAM_LANGUAGE_DEF; } if (name == OS_PARAM_OUTPUT_ENCODING) { return OS_PARAM_OUTPUT_ENCODING_DEF; } // At this point, if a parameter is optional, just omit it. if (optional) { return ""; } // Replace unsupported parameters that only have hardcoded default values. for (let param of OS_UNSUPPORTED_PARAMS) { if (name == param[0]) { return param[1]; } } // Don't replace unknown non-optional parameters. return match; }); } /** * EngineURL holds a query URL and all associated parameters. */ export class EngineURL { /** @type {QueryParameter[]} */ params = []; /** @type {string[]} */ rels = []; /** @type {string} */ template; /** * The name of the parameter used for the search term. * * @type {?string} */ #searchTermParam = null; /** * Creates an EngineURL. * * @param {string} mimeType * The name of the MIME type of the search results returned by this URL. * @param {string} requestMethod * The HTTP request method. Must be a case insensitive value of either * "GET" or "POST". * @param {string} template * The URL to which search queries should be sent. For GET requests, * must contain the string "{searchTerms}", to indicate where the user * entered search terms should be inserted. * * @see https://web.archive.org/web/20060203040832/http://opensearch.a9.com/spec/1.1/querysyntax/#urltag * * @throws NS_ERROR_NOT_IMPLEMENTED if aType is unsupported. */ constructor(mimeType, requestMethod, template) { if (!mimeType || !requestMethod || !template) { throw Components.Exception( "missing mimeType, method or template for EngineURL!", Cr.NS_ERROR_INVALID_ARG ); } var method = requestMethod.toUpperCase(); var type = mimeType.toLowerCase(); if (method != "GET" && method != "POST") { throw Components.Exception( 'method passed to EngineURL must be "GET" or "POST"', Cr.NS_ERROR_INVALID_ARG ); } this.type = type; this.method = method; var templateURI = lazy.SearchUtils.makeURI(template); if (!templateURI) { throw Components.Exception( "new EngineURL: template is not a valid URI!", Cr.NS_ERROR_FAILURE ); } switch (templateURI.scheme) { case "http": case "https": this.template = template; break; default: throw Components.Exception( "new EngineURL: template uses invalid scheme!", Cr.NS_ERROR_FAILURE ); } this.templateHost = templateURI.host; // It's possible that the search term parameter // is part of the template. let urlParms = new URLSearchParams(templateURI.query); for (let [name, value] of urlParms.entries()) { if (value == "{searchTerms}") { this.#searchTermParam = name; } } } /** * Adds a QueryParameter object to the list of params. * * @param {QueryParameter} param * The QueryParameter to add. */ addQueryParameter(param) { if (param.value == "{searchTerms}") { this.setSearchTermParamName(param.name); return; } this.params.push(param); } /** * Adds a QueryParameter by name and value. * This exists because it's a frequent operation and it allows * other files to add QueryParameters without importing QueryParameter. * * @param {string} name name of the parameter * @param {string} value value of the parameter */ addParam(name, value) { this.addQueryParameter(new QueryParameter(name, value)); } /** * Sets the name of the search term parameter and * adds it to the list of query parameters. * * @param {string} name * The name of the parameter. */ setSearchTermParamName(name) { if (this.#searchTermParam) { lazy.logConsole.warn( "set searchTermParamName: searchTermParamName was set twice." ); } this.params.push(new QueryParameter(name, "{searchTerms}")); this.#searchTermParam = name; } /** * Returns the name of the parameter used for the search term. * * @returns {?string} * A string which is the name of the parameter, or null if no parameter * can be found (e.g. if search terms are contained within the URL). */ get searchTermParamName() { return this.#searchTermParam; } /** * Returns a complete URL with parameter data that can be used for submitting * a suggestion query or loading a search page. * * @param {string} searchTerms * The user's search terms. * @param {string} queryCharset * The character set that is being used for the query. * @returns {Submission} * The submission data containing the URL and post data for the URL. */ getSubmission(searchTerms, queryCharset) { let escapedSearchTerms; try { escapedSearchTerms = Services.textToSubURI.ConvertAndEscape( queryCharset, searchTerms ); } catch (ex) { lazy.logConsole.warn( "getSubmission: Falling back to default queryCharset!" ); escapedSearchTerms = Services.textToSubURI.ConvertAndEscape( lazy.SearchUtils.DEFAULT_QUERY_CHARSET, searchTerms ); } let templateURI = new URL(this.template); let paramString = this.#encodeParams(escapedSearchTerms, queryCharset); let postData = null; let query = paramSubstitution( templateURI.search, escapedSearchTerms, queryCharset ); if (this.method == "GET" && paramString) { // Query parameters may be specified in the template url AND in `this.params`. // Thus, we need to supply both with the search terms and join them. if (query) { query += "&" + paramString; } else { query = paramString; } } else if (this.method == "POST") { // POST method requests must wrap the encoded text in a MIME // stream and supply that as POSTDATA. let stringStream = Cc[ "@mozilla.org/io/string-input-stream;1" ].createInstance(Ci.nsIStringInputStream); stringStream.setByteStringData(paramString); postData = Cc["@mozilla.org/network/mime-input-stream;1"].createInstance( Ci.nsIMIMEInputStream ); postData.addHeader("Content-Type", "application/x-www-form-urlencoded"); postData.setData(stringStream); } templateURI.search = query; // textToSubURI encodes spaces with '+', but we want to use '%20' if the // search terms are part of the file path or ref. We only use '+' if they // are part of a query parameter. let urlSearchTerms = escapedSearchTerms.replaceAll("+", "%20"); templateURI.pathname = paramSubstitution( // The braces in filePath are percent-encoded, so we // decode them to ensure paramSubstitution finds them. decodeURIComponent(templateURI.pathname), urlSearchTerms, queryCharset ); templateURI.hash = paramSubstitution( templateURI.hash, urlSearchTerms, queryCharset ); return new Submission(templateURI.URI, postData); } /** * Returns a application/x-www-form-urlencoded representation of the params * using the specified search term (name=value&name=value&name=value). * Can be used for GET and POST. * * @param {string} escapedSearchTerms * The user's search terms escaped with the correct charset. * @param {string} queryCharset * The character set that is being used for the query. * @returns {string} * Parameter string containing the search terms. */ #encodeParams(escapedSearchTerms, queryCharset) { let dataArray = []; for (let param of this.params) { // QueryPreferenceParameters might not have a preferenced saved, or a valid value. if (param.value != null) { let value = paramSubstitution( param.value, escapedSearchTerms, queryCharset ); dataArray.push(param.name + "=" + value); } } return dataArray.join("&"); } _hasRelation(rel) { return this.rels.some(e => e == rel.toLowerCase()); } _initWithJSON(json) { if (!json.params) { return; } this.rels = json.rels; for (let param of json.params) { // mozparam and purpose were only supported for app-provided engines. // Always ignore them for engines loaded from JSON. if (!param.mozparam && !param.purpose) { this.addParam(param.name, param.value); } } } /** * Creates a JavaScript object that represents this URL. * * @returns {object} * An object suitable for serialization as JSON. */ toJSON() { var json = { params: this.params, rels: this.rels, template: this.template, }; if (this.type != lazy.SearchUtils.URL_TYPE.SEARCH) { json.type = this.type; } if (this.method != "GET") { json.method = this.method; } return json; } } /** * SearchEngine is the base class that all search engine classes inherit from. * * @implements {nsISearchEngine} */ export class SearchEngine { QueryInterface = ChromeUtils.generateQI(["nsISearchEngine"]); // Data set by the user. _metaData = {}; // Anonymized path of where we initially loaded the engine from. // This will stay null for engines installed in the profile before we moved // to a JSON storage. _loadPath = null; // The engine's name. _name = null; // The name of the charset used to submit the search terms. _queryCharset = null; // The order hint from the configuration (if any). _orderHint = null; // The telemetry id from the configuration (if any). _telemetryId = null; // Set to true once the engine has been added to the store, and the initial // notification sent. This allows to skip sending notifications during // initialization. _engineAddedToStore = false; // The aliases coming from the engine definition (via webextension // keyword field for example). _definedAliases = []; // The urls associated with this engine. _urls = []; // The known public suffix of the search url, cached in memory to avoid // repeated look-ups. _searchUrlPublicSuffix = null; /** * The unique id of the Search Engine. * * @type {string} */ #id; /** * The URL to report the search to. * * @type {?string} */ clickUrl = null; /** * Creates a Search Engine. * * @param {object} options * The options for this search engine. * @param {string} [options.id] * The identifier to use for this engine, if none is specified a random * uuid is created. * @param {string} options.loadPath * The path of the engine was originally loaded from. Should be anonymized. */ constructor(options) { this.#id = options.id ?? this.#uuid(); if (!("loadPath" in options)) { throw new Error("loadPath missing from options."); } this._loadPath = options.loadPath; } /** * Attempts to find an EngineURL object in the set of EngineURLs for * this Engine that has the given type string. (This corresponds to the * "type" attribute in the "Url" node in the OpenSearch spec.) * * @param {string} type * The type to match the EngineURL's type attribute. * @param {string} [rel] * Only return URLs that with this rel value. * @returns {EngineURL|null} * Returns the first matching URL found, null otherwise. */ _getURLOfType(type, rel) { for (let url of this._urls) { if (url.type == type && (!rel || url._hasRelation(rel))) { return url; } } return null; } /** * Directly adds a local icon to the icon map without notifying observers. * Icon must be square and should be behind a local URL * (i.e., data, or moz-extension). * * @param {string} iconURL * String with the icon's URI. * @param {number} size * Width and height of the icon. * @param {boolean} override * Whether the new URI should override an existing one. */ _addIconToMap(iconURL, size, override = true) { // Use an object instead of a Map() because it needs to be serializable. this._iconMapObj = this._iconMapObj || {}; if (!(size in this._iconMapObj) || override) { this._iconMapObj[size] = iconURL; } } /** * Adds an icon from an http[s], data, or moz-extension URL to the * icon map, downloading http[s] icons and rescaling icons with a size * larger than MAX_ICON_SIZE. * * @param {string} iconURL * A URI string pointing to the engine's icon. * Must have http[s], data, or moz-extension protocol. * @param {number} [size] * Width and height of the icon (determined automatically if not provided). * @param {boolean} [override] * Whether the new URI should override an existing one. * @returns {Promise} * Resolves when the icon was set. * Rejects with an Error if there was an error. */ async _setIcon(iconURL, size, override = true) { lazy.logConsole.debug( "_setIcon: Setting icon url for", this.name, "to", limitURILength(iconURL) ); [iconURL, size] = await this._downloadAndRescaleIcon(iconURL, size); this._addIconToMap(iconURL, size, override); if (this._engineAddedToStore) { lazy.SearchUtils.notifyAction( this, lazy.SearchUtils.MODIFIED_TYPE.ICON_CHANGED ); } } /** * Downloads the requested icon if the url is http[s], determines * its size if not provided and rescales the icon if its size exceeds * MAX_ICON_SIZE. * * @param {string} iconURL * A URI string pointing to the engine's icon. * Must have http[s], data, or moz-extension protocol. * @param {number} [size] * Width and height of the icon (determined automatically if not provided). * @returns {Promise<[string, number]>} * Resolves to [dataURL, size] if successful and rejects if there was an error. */ async _downloadAndRescaleIcon(iconURL, size) { let uri = lazy.SearchUtils.makeURI(iconURL); if (!uri) { throw new Error(`Invalid URI`); } switch (uri.scheme) { case "moz-extension": { if (!size) { let [byteArray, contentType] = await lazy.SearchUtils.fetchIcon(uri); size = lazy.SearchUtils.decodeSize(byteArray, contentType, 16); } return [iconURL, size]; } // We also fetch data URLs to ensure the size doesn't exceed MAX_ICON_SIZE. case "data": case "http": case "https": { let [byteArray, contentType] = await lazy.SearchUtils.fetchIcon(uri); if (byteArray.length > lazy.SearchUtils.MAX_ICON_SIZE) { lazy.logConsole.debug( `Rescaling icon for search engine ${this.name}.` ); [byteArray, contentType] = lazy.SearchUtils.rescaleIcon( byteArray, contentType, 32 ); size = 32; } if (!size) { size = lazy.SearchUtils.decodeSize(byteArray, contentType, 16); } let dataURL = "data:" + contentType + ";base64," + byteArray.toBase64(); return [dataURL, size]; } default: throw new Error(`URL scheme ${uri.scheme} is not allowed`); } } /** * Initialize an EngineURL object from metadata. * * @param {string} type * The url type. * @param {object} params * The URL parameters. * @param {string | Array} [params.getParams] * Any parameters for a GET method. This is either a query string, or * an array of objects which have name/value pairs. * @param {string} [params.method] * The type of method, defaults to GET. * @param {string | Array} [params.postParams] * Any parameters for a POST method. This is either a query string, or * an array of objects which have name/value pairs. * @param {string} params.template * The url template. * @returns {EngineURL} * The newly created EngineURL. */ _getEngineURLFromMetaData(type, params) { let url = new EngineURL(type, params.method || "GET", params.template); if (params.postParams) { if (Array.isArray(params.postParams)) { for (let { name, value } of params.postParams) { url.addParam(name, value); } } else { for (let [name, value] of new URLSearchParams(params.postParams)) { url.addParam(name, value); } } } if (params.getParams) { if (Array.isArray(params.getParams)) { for (let { name, value } of params.getParams) { url.addParam(name, value); } } else { for (let [name, value] of new URLSearchParams(params.getParams)) { url.addParam(name, value); } } } return url; } /** * Initialize this engine object using a WebExtension style object. * * @param {object} details * The details of the engine. * @param {string} details.name * The name of the engine. * @param {string} details.keyword * The keyword for the engine. * @param {string} [details.iconURL] * The url to use for the icon of the engine. * @param {string} details.search_url * The search url template for the engine. * @param {string} [details.search_url_get_params] * The search url parameters for use with the GET method. * @param {string} [details.search_url_post_params] * The search url parameters for use with the POST method. * @param {string} [details.suggest_url] * The suggestion url template for the engine. * @param {string} [details.suggest_url_get_params] * The suggestion url parameters for use with the GET method. * @param {string} [details.suggest_url_post_params] * The suggestion url parameters for use with the POST method. * @param {string} [details.encoding] * The encoding to use for the engine. */ _initWithDetails(details) { this._name = details.name.trim(); this._definedAliases = []; if (Array.isArray(details.keyword)) { this._definedAliases = details.keyword.map(k => k.trim()); } else if (details.keyword?.trim()) { this._definedAliases = [details.keyword?.trim()]; } if (details.iconURL) { this._setIcon(details.iconURL).catch(e => lazy.logConsole.warn( `Error while setting icon for search engine ${details.name}:`, e.message ) ); } this._setUrls(details); } /** * This sets the urls for the search engine based on the supplied parameters. * If you add anything here, please consider if it needs to be handled in the * overrideWithEngine / removeExtensionOverride functions as well. * * @param {object} details * The details of the engine. * @param {string} details.search_url * The search url template for the engine. * @param {string} [details.search_url_get_params] * The search url parameters for use with the GET method. * @param {string} [details.search_url_post_params] * The search url parameters for use with the POST method. * @param {string} [details.suggest_url] * The suggestion url template for the engine. * @param {string} [details.suggest_url_get_params] * The suggestion url parameters for use with the GET method. * @param {string} [details.suggest_url_post_params] * The suggestion url parameters for use with the POST method. * @param {string} [details.encoding] * The encoding to use for the engine. */ _setUrls(details) { let postParams = details.search_url_post_params || ""; let url = this._getEngineURLFromMetaData(lazy.SearchUtils.URL_TYPE.SEARCH, { method: (postParams && "POST") || "GET", // AddonManager will sometimes encode the URL via `new URL()`. We want // to ensure we're always dealing with decoded urls. template: decodeURI(details.search_url), getParams: details.search_url_get_params || "", postParams, }); this._urls.push(url); if (details.suggest_url) { let suggestPostParams = details.suggest_url_post_params || ""; url = this._getEngineURLFromMetaData( lazy.SearchUtils.URL_TYPE.SUGGEST_JSON, { method: (suggestPostParams && "POST") || "GET", // suggest_url doesn't currently get encoded. template: details.suggest_url, getParams: details.suggest_url_get_params || "", postParams: suggestPostParams, } ); this._urls.push(url); } if (details.encoding) { this._queryCharset = details.encoding; } } checkSearchUrlMatchesManifest(details) { let existingUrl = this._getURLOfType(lazy.SearchUtils.URL_TYPE.SEARCH); let newUrl = this._getEngineURLFromMetaData( lazy.SearchUtils.URL_TYPE.SEARCH, { method: (details.search_url_post_params && "POST") || "GET", // AddonManager will sometimes encode the URL via `new URL()`. We want // to ensure we're always dealing with decoded urls. template: decodeURI(details.search_url), getParams: details.search_url_get_params || "", postParams: details.search_url_post_params || "", } ); let existingSubmission = existingUrl.getSubmission("", this.queryCharset); let newSubmission = newUrl.getSubmission("", this.queryCharset); return ( existingSubmission.uri.equals(newSubmission.uri) && existingSubmission.postData?.data.data == newSubmission.postData?.data.data ); } /** * Overrides the urls/parameters with those of the provided engine or extension. * The url parameters are not saved to the search settings - the code handling * the extension should set these on every restart, this avoids potential * third party modifications and means that we can verify the WebExtension is * still in the allow list. * * @param {object} options * The options for this function. * @param {AddonSearchEngine|OpenSearchEngine} [options.engine] * The search engine to override with this engine. If not specified, `manifest` * must be provided. * @param {object} [options.extension] * An object representing the WebExtensions. If not specified, * `engine` must be provided */ overrideWithEngine({ engine, extension }) { this._overriddenData = { urls: this._urls, queryCharset: this._queryCharset, }; if (engine) { // Copy any saved user data (alias, order etc). this.copyUserSettingsFrom(engine); this._urls = engine._urls; this.setAttr("overriddenBy", engine._extensionID ?? engine.id); if (engine instanceof lazy.OpenSearchEngine) { this.setAttr("overriddenByOpenSearch", engine.toJSON()); } } else { this._urls = []; this.setAttr("overriddenBy", extension.id); this._setUrls( extension.manifest.chrome_settings_overrides.search_provider ); } if (this.searchURLWithNoTerms.spec != this.getAttr("overriddenURL")) { this.setAttr("overriddenURL", this.searchURLWithNoTerms.spec, true); } } /** * Resets the overrides for the engine if it has been overridden. */ removeExtensionOverride() { if (this.getAttr("overriddenBy")) { // If the attribute is set, but there is no data, skip it. Worst case, // the urls will be reset on a restart. if (this._overriddenData) { this._urls = this._overriddenData.urls; this._queryCharset = this._overriddenData.queryCharset; delete this._overriddenData; } else { lazy.logConsole.error( `${this._name} had overriddenBy set, but no _overriddenData` ); } this.clearAttr("overriddenBy"); this.clearAttr("overriddenURL"); lazy.SearchUtils.notifyAction( this, lazy.SearchUtils.MODIFIED_TYPE.CHANGED ); } } /** * Copies settings from the supplied search engine. Typically used for * restoring settings when removing an override. * * @param {SearchEngine|object} engine * The engine to copy the settings from, or the engine settings from * the user's saved settings. */ copyUserSettingsFrom(engine) { for (let attribute of USER_ATTRIBUTES) { if (attribute in engine._metaData) { this._metaData[attribute] = engine._metaData[attribute]; } } } /** * Init from a JSON record. * * @param {object} json * The json record to use. */ _initWithJSON(json) { this.#id = json.id ?? this.#id; this._name = json._name; this._queryCharset = json.queryCharset || lazy.SearchUtils.DEFAULT_QUERY_CHARSET; this._iconMapObj = json._iconMapObj || null; this._metaData = json._metaData || {}; this._orderHint = json._orderHint || null; this._definedAliases = json._definedAliases || []; // These changed keys in Firefox 80, maintain the old keys // for backwards compatibility. if (json._definedAlias) { this._definedAliases.push(json._definedAlias); } this._filePath = json.filePath || json._filePath || null; for (let i = 0; i < json._urls.length; ++i) { let url = json._urls[i]; let engineURL = new EngineURL( url.type || lazy.SearchUtils.URL_TYPE.SEARCH, url.method || "GET", url.template ); engineURL._initWithJSON(url); this._urls.push(engineURL); } } /** * Creates a JavaScript object that represents this engine. * * @returns {object} * An object suitable for serialization as JSON. */ toJSON() { const fieldsToCopy = [ "id", "_name", "_loadPath", "_iconMapObj", "_metaData", "_urls", "_orderHint", "_telemetryId", "_filePath", "_definedAliases", ]; let json = {}; for (const field of fieldsToCopy) { if (field in this) { json[field] = this[field]; } } if (this.queryCharset != lazy.SearchUtils.DEFAULT_QUERY_CHARSET) { json.queryCharset = this.queryCharset; } return json; } setAttr(name, val, sendNotification = false) { // Cache whether the attribute actually changes so we don't lose that info // when updating `_metaData`. let hasChangedAttr = val != this[name]; this._metaData[name] = val; if (hasChangedAttr && sendNotification) { lazy.SearchUtils.notifyAction( this, lazy.SearchUtils.MODIFIED_TYPE.CHANGED ); } } getAttr(name) { return this._metaData[name] || undefined; } clearAttr(name) { delete this._metaData[name]; } /** * @type {string} * The partner code being used by this search engine in the Search URL. */ get partnerCode() { return ""; } /** * Loads engine settings (_metaData) from the list of settings, finding * the appropriate details for this engine. * * @param {object} [settings] * The saved settings for the user. */ _loadSettings(settings) { if (!settings) { return; } let engineSettings = lazy.SearchSettings.findSettingsForEngine( settings, this.id, this.name ); if (engineSettings?._metaData) { this._metaData = structuredClone(engineSettings._metaData); } } /** * Gets the order hint for this engine. This is determined from the search * configuration when the engine is initialized. * * @type {number} */ get orderHint() { return this._orderHint; } /** * Get the user-defined alias. * * @type {string} */ get alias() { return this.getAttr("alias") || ""; } set alias(val) { var value = val ? val.trim() : ""; this.setAttr("alias", value, true); } /** * Returns a list of aliases, including a user defined alias and * a list defined by webextension keywords. * * @returns {string[]} */ get aliases() { return [ ...(this.getAttr("alias") ? [this.getAttr("alias")] : []), ...this._definedAliases, ]; } /** * Returns the appropriate identifier to use for telemetry. It is based on * the following order: * * - telemetryId: The telemetry id from the configuration, or derived from * the WebExtension name. * - other-: The engine name prefixed by `other-` for non-app-provided * engines. * * @returns {string} */ get telemetryId() { let telemetryId = this._telemetryId || `other-${this.name}`; if (this.getAttr("overriddenBy")) { return telemetryId + "-addon"; } return telemetryId; } /** * Return the built-in identifier of app-provided engines. * * @returns {string|null} * Returns a valid if this is a built-in engine, null otherwise. */ get identifier() { // No identifier if If the engine isn't app-provided return this.isAppProvided ? this._telemetryId : null; } get hidden() { return this.getAttr("hidden") || false; } set hidden(val) { var value = !!val; this.setAttr("hidden", value, true); } get hideOneOffButton() { return this.getAttr("hideOneOffButton") || false; } set hideOneOffButton(val) { const value = !!val; this.setAttr("hideOneOffButton", value, true); } /** * Whether or not this engine is provided by the application, e.g. it is * in the list of configured search engines. * * @returns {boolean} * This returns false for most engines, but may be overridden by particular * engine types, such as add-on engines which are used by the application. */ get isAppProvided() { return false; } /** * Whether or not this engine is an in-memory only search engine. * These engines are typically application provided or policy engines, * where they are loaded every time on SearchService initialization * using the policy JSON or the extension manifest. Minimal details of the * in-memory engines are saved to disk, but they are never loaded * from the user's saved settings file. * * @returns {boolean} * This results false for most engines, but may be overridden by particular * engine types, such as add-on engines and policy engines. */ get inMemory() { return false; } /** * If this engine has been overridden by a third-party engine, the id returned * will be the engine it was overriden by. Otherwise this will return null. * * @returns {?string} */ get overriddenById() { return this.getAttr("overriddenBy"); } get isGeneralPurposeEngine() { return false; } get _hasUpdates() { return false; } get name() { return this._name; } /** * Anonymized path of where we initially loaded the engine from. */ get loadPath() { return this._loadPath; } get queryCharset() { return this._queryCharset || lazy.SearchUtils.DEFAULT_QUERY_CHARSET; } /** * Gets an object that contains information about what to send to the search * engine, for a request. This will be a URI and may also include data for POST * requests. * * @param {string} searchTerms * The search term(s) for the submission. * @param {lazy.SearchUtils.URL_TYPE} [responseType] * The MIME type that we'd like to receive in response * to this submission. If null, will default to "text/html". * @returns {nsISearchSubmission|null} * The submission data. If no appropriate submission can be determined for * the request type, this may be null. */ getSubmission(searchTerms, responseType) { // We can't use a default parameter as that doesn't work correctly with // the idl interfaces. if (!responseType) { responseType = lazy.SearchUtils.URL_TYPE.SEARCH; } var url = this._getURLOfType(responseType); if (!url) { return null; } if ( !searchTerms && (responseType == lazy.SearchUtils.URL_TYPE.SEARCH || responseType == lazy.SearchUtils.URL_TYPE.SUGGEST_JSON) ) { lazy.logConsole.warn("getSubmission: searchTerms is empty!"); } return url.getSubmission(searchTerms, this.queryCharset); } /** * Returns a search URL with no search terms. This is typically used for * purposes where we want to check something on the URL, but not use it for * an actual submission to the search engine. * * @returns {nsIURI} */ get searchURLWithNoTerms() { return this._getURLOfType(lazy.SearchUtils.URL_TYPE.SEARCH).getSubmission( "", this.queryCharset ).uri; } /** * Returns the search term of a possible search result URI if and only if: * - The URI has the same scheme, host, and path as the engine. * - All query parameters of the URI have a matching name and value in the engine. * - An exception to the equality check is the engine's termsParameterName * value, which contains a placeholder, i.e. {searchTerms}. * - If an engine has query parameters with "null" values, they will be ignored. * * @param {nsIURI} uri * A URI that may or may not be from a search result matching the engine. * * @returns {string} * A string representing the termsParameterName value of the URI, * or an empty string if the URI isn't matched to the engine. */ searchTermFromResult(uri) { let url = this._getURLOfType(lazy.SearchUtils.URL_TYPE.SEARCH); if (!url) { return ""; } // To avoid unnecessarily comparing search parameters, start by ensuring // that the origin and path of both URLs are identical. // Note that URIs encode the path as percent encoded characters, while the // path of the URL from search config is not percent encoded. Thus, we // convert both strings into URL objects to ensure consistent comparisons. let url1 = new URL(url.template); let url2 = URL.fromURI(uri); if (url1.origin != url2.origin || url1.pathname != url2.pathname) { return ""; } let engineParams; if (url.params.length) { engineParams = new URLSearchParams(); for (let { name, value } of url.params) { // Some values might be null, so avoid adding // them since the input is unlikely to have it too. if (value) { // Use append() rather than set() so multiple // values of the same name can be stored. engineParams.append(name, value); } } } else { // Try checking the template for the presence of query params. engineParams = url1.searchParams; } let uriParams = url2.searchParams; if ( new Set([...uriParams.keys()]).size != new Set([...engineParams.keys()]).size ) { return ""; } let termsParameterName = this.searchUrlQueryParamName; for (let [name, value] of uriParams.entries()) { // Don't check the name matching the search // query because its value will differ. if (name == termsParameterName) { continue; } // All params of an input must have a matching // key and value in the list of engine parameters. if (!engineParams.getAll(name).includes(value)) { return ""; } } // An engine can use a non UTF-8 charset, which URLSearchParams // might not parse properly. Convert the terms parameter value // from the original input using the appropriate charset. if (this.queryCharset.toLowerCase() != "utf-8") { let name = `${termsParameterName}=`; let queryString = uri.query .split("&") .filter(str => str.startsWith(name)) .pop(); return Services.textToSubURI.UnEscapeAndConvert( this.queryCharset, queryString.substring(queryString.indexOf("=") + 1).replace(/\+/g, " ") ); } return uriParams.get(termsParameterName) ?? ""; } get searchUrlQueryParamName() { return ( this._getURLOfType(lazy.SearchUtils.URL_TYPE.SEARCH) .searchTermParamName || "" ); } get searchUrlPublicSuffix() { if (this._searchUrlPublicSuffix != null) { return this._searchUrlPublicSuffix; } let searchURLPublicSuffix = Services.eTLD.getKnownPublicSuffix( this.searchURLWithNoTerms ); return (this._searchUrlPublicSuffix = searchURLPublicSuffix); } // from nsISearchEngine supportsResponseType(type) { return this._getURLOfType(type) != null; } // from nsISearchEngine get searchUrlDomain() { let url = this._getURLOfType(lazy.SearchUtils.URL_TYPE.SEARCH); if (url) { return url.templateHost; } return ""; } /** * @returns {string} * URL to the main page of the search engine. * Uses the first URL of type SEARCH_FORM or the pre path * of the search URL as a fallback if no such URL exists. */ get searchForm() { let url = this._getURLOfType(lazy.SearchUtils.URL_TYPE.SEARCH_FORM); if (url) { return url.getSubmission("", this.queryCharset).uri.spec; } return this.searchURLWithNoTerms.prePath; } /** * @returns {object} * URL parsing properties used by _buildParseSubmissionMap. */ getURLParsingInfo() { let url = this._getURLOfType(lazy.SearchUtils.URL_TYPE.SEARCH); if (!url || url.method != "GET") { return null; } let termsParameterName = url.searchTermParamName; if (!termsParameterName) { return null; } let templateUrl = Services.io.newURI(url.template); return { mainDomain: templateUrl.host, path: templateUrl.filePath.toLowerCase(), termsParameterName, }; } get wrappedJSObject() { return this; } /** * Returns the icon URL for the search engine closest to the preferred width * or undefined if the engine has no icons. * * @param {number} [preferredWidth] * Width of the requested icon. If not specified, it is assumed that * 16x16 is desired. * @returns {Promise} */ async getIconURL(preferredWidth) { // XPCOM interfaces pass optional number parameters as 0. preferredWidth ||= 16; if (!this._iconMapObj) { return undefined; } let availableWidths = Object.keys(this._iconMapObj).map(k => parseInt(k)); if (!availableWidths.length) { return undefined; } let bestWidth = lazy.SearchUtils.chooseIconSize( preferredWidth, availableWidths ); return this._iconMapObj[bestWidth]; } /** * Opens a speculative connection to the engine's search URI * (and suggest URI, if different) to reduce request latency * * @param {object} options * The options object * @param {Window} options.window * The content window for the window performing the search. * @param {object} options.originAttributes * The originAttributes for performing the search * @throws NS_ERROR_INVALID_ARG if options is omitted or lacks required * elements */ speculativeConnect(options) { if (!options || !options.window) { console.error( "invalid options arg passed to nsISearchEngine.speculativeConnect" ); throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); } let connector = Services.io.QueryInterface(Ci.nsISpeculativeConnect); let searchURI = this.searchURLWithNoTerms; let callbacks = options.window.docShell.QueryInterface(Ci.nsILoadContext); // Using the content principal which is constructed by the search URI // and given originAttributes. If originAttributes are not given, we // fallback to use the docShell's originAttributes. let attrs = options.originAttributes; if (!attrs) { attrs = options.window.docShell.getOriginAttributes(); } let principal = Services.scriptSecurityManager.createContentPrincipal( searchURI, attrs ); try { connector.speculativeConnect(searchURI, principal, callbacks, false); } catch (e) { // Can't setup speculative connection for this url, just ignore it. console.error(e); } if (this.supportsResponseType(lazy.SearchUtils.URL_TYPE.SUGGEST_JSON)) { let suggestURI = this.getSubmission( "dummy", lazy.SearchUtils.URL_TYPE.SUGGEST_JSON ).uri; if (suggestURI.prePath != searchURI.prePath) { try { connector.speculativeConnect(suggestURI, principal, callbacks, false); } catch (e) { // Can't setup speculative connection for this url, just ignore it. console.error(e); } } } } get id() { return this.#id; } /** * Generates an UUID. * * @returns {string} * An UUID string, without leading or trailing braces. */ #uuid() { let uuid = Services.uuid.generateUUID().toString(); return uuid.slice(1, uuid.length - 1); } } /** * @implements {nsISearchSubmission}. */ class Submission { QueryInterface = ChromeUtils.generateQI(["nsISearchSubmission"]); constructor(uri, postData = null) { this._uri = uri; this._postData = postData; } get uri() { return this._uri; } get postData() { return this._postData; } }