/* 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 */ import { EngineURL, SearchEngine, } from "resource://gre/modules/SearchEngine.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", }); XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => { return console.createInstance({ prefix: "OpenSearchEngine", maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn", }); }); const OPENSEARCH_NS_10 = "http://a9.com/-/spec/opensearch/1.0/"; const OPENSEARCH_NS_11 = "http://a9.com/-/spec/opensearch/1.1/"; // Although the specification at http://opensearch.a9.com/spec/1.1/description/ // gives the namespace names defined above, many existing OpenSearch engines // are using the following versions. We therefore allow either. const OPENSEARCH_NAMESPACES = [ OPENSEARCH_NS_11, OPENSEARCH_NS_10, "http://a9.com/-/spec/opensearchdescription/1.1/", "http://a9.com/-/spec/opensearchdescription/1.0/", ]; const OPENSEARCH_LOCALNAME = "OpenSearchDescription"; const MOZSEARCH_NS_10 = "http://www.mozilla.org/2006/browser/search/"; const MOZSEARCH_LOCALNAME = "SearchPlugin"; /** * Ensures an assertion is met before continuing. Should be used to indicate * fatal errors. * * @param {*} assertion * An assertion that must be met * @param {string} message * A message to display if the assertion is not met * @param {number} resultCode * The NS_ERROR_* value to throw if the assertion is not met * @throws resultCode * If the assertion fails. */ function ENSURE_WARN(assertion, message, resultCode) { if (!assertion) { throw Components.Exception(message, resultCode); } } /** * OpenSearchEngine represents an OpenSearch base search engine. */ export class OpenSearchEngine extends SearchEngine { // The data describing the engine, in the form of an XML document element. _data = null; // The number of days between update checks for new versions _updateInterval = null; // The url to check at for a new update _updateURL = null; // The url to check for a new icon _iconUpdateURL = null; /** * Creates a OpenSearchEngine. * * @param {object} [options] * The options object * @param {object} [options.json] * An object that represents the saved JSON settings for the engine. * @param {boolean} [options.shouldPersist] * A flag indicating whether the engine should be persisted to disk and made * available wherever engines are used (e.g. it can be set as the default * search engine, used for search shortcuts, etc.). Non-persisted engines * are intended for more limited or temporary use. Defaults to true. */ constructor(options = {}) { super({ // We don't know what this is until after it has loaded, so add a placeholder. loadPath: options.json?._loadPath ?? "[opensearch]loading", }); if (options.json) { this._initWithJSON(options.json); this._updateInterval = options.json._updateInterval ?? null; this._updateURL = options.json._updateURL ?? null; this._iconUpdateURL = options.json._iconUpdateURL ?? null; } this._shouldPersist = options.shouldPersist ?? true; } /** * Creates a JavaScript object that represents this engine. * * @returns {object} * An object suitable for serialization as JSON. */ toJSON() { let json = super.toJSON(); json._updateInterval = this._updateInterval; json._updateURL = this._updateURL; json._iconUpdateURL = this._iconUpdateURL; return json; } /** * Retrieves the engine data from a URI. Initializes the engine, flushes to * disk, and notifies the search service once initialization is complete. * * @param {string|nsIURI} uri * The uri to load the search plugin from. * @param {Function} [callback] * A callback to receive any details of errors. */ install(uri, callback) { let loadURI = uri instanceof Ci.nsIURI ? uri : lazy.SearchUtils.makeURI(uri); if (!loadURI) { throw Components.Exception( loadURI, "Must have URI when calling _install!", Cr.NS_ERROR_UNEXPECTED ); } if (!/^https?$/i.test(loadURI.scheme)) { throw Components.Exception( "Invalid URI passed to SearchEngine constructor", Cr.NS_ERROR_INVALID_ARG ); } lazy.logConsole.debug("_install: Downloading engine from:", loadURI.spec); var chan = lazy.SearchUtils.makeChannel(loadURI); if (this._engineToUpdate && chan instanceof Ci.nsIHttpChannel) { var lastModified = this._engineToUpdate.getAttr("updatelastmodified"); if (lastModified) { chan.setRequestHeader("If-Modified-Since", lastModified, false); } } this._uri = loadURI; var listener = new lazy.SearchUtils.LoadListener( chan, /(^text\/|xml$)/, this._onLoad.bind(this, callback) ); chan.notificationCallbacks = listener; chan.asyncOpen(listener); } /** * Handle the successful download of an engine. Initializes the engine and * triggers parsing of the data. The engine is then flushed to disk. Notifies * the search service once initialization is complete. * * @param {Function} callback * A callback to receive success or failure notifications. May be null. * @param {Array} bytes * The loaded search engine data. */ _onLoad(callback, bytes) { let onError = errorCode => { if (this._engineToUpdate) { lazy.logConsole.warn("Failed to update", this._engineToUpdate.name); } callback?.(errorCode); }; if (!bytes) { onError(Ci.nsISearchService.ERROR_DOWNLOAD_FAILURE); return; } var parser = new DOMParser(); var doc = parser.parseFromBuffer(bytes, "text/xml"); this._data = doc.documentElement; try { this._initFromData(); } catch (ex) { lazy.logConsole.error("_onLoad: Failed to init engine!", ex); if (ex.result == Cr.NS_ERROR_FILE_CORRUPTED) { onError(Ci.nsISearchService.ERROR_ENGINE_CORRUPTED); } else { onError(Ci.nsISearchService.ERROR_DOWNLOAD_FAILURE); } return; } if (this._engineToUpdate) { let engineToUpdate = this._engineToUpdate.wrappedJSObject; // Preserve metadata and loadPath. Object.keys(engineToUpdate._metaData).forEach(key => { this.setAttr(key, engineToUpdate.getAttr(key)); }); this._loadPath = engineToUpdate._loadPath; // Keep track of the last modified date, so that we can make conditional // requests for future updates. this.setAttr("updatelastmodified", new Date().toUTCString()); // Set the new engine's icon, if it doesn't yet have one. if (!this._iconURI && engineToUpdate._iconURI) { this._iconURI = engineToUpdate._iconURI; } } else { // Check that when adding a new engine (e.g., not updating an // existing one), a duplicate engine does not already exist. if (Services.search.getEngineByName(this.name)) { onError(Ci.nsISearchService.ERROR_DUPLICATE_ENGINE); lazy.logConsole.debug("_onLoad: duplicate engine found, bailing"); return; } this._loadPath = OpenSearchEngine.getAnonymizedLoadPath( lazy.SearchUtils.sanitizeName(this.name), this._uri ); if (this._extensionID) { this._loadPath += ":" + this._extensionID; } this.setAttr( "loadPathHash", lazy.SearchUtils.getVerificationHash(this._loadPath) ); } if (this._shouldPersist) { // Notify the search service of the successful load. It will deal with // updates by checking this._engineToUpdate. lazy.SearchUtils.notifyAction( this, lazy.SearchUtils.MODIFIED_TYPE.LOADED ); } callback?.(); } /** * Initialize this Engine object from the collected data. */ _initFromData() { ENSURE_WARN( this._data, "Can't init an engine with no data!", Cr.NS_ERROR_UNEXPECTED ); // Ensure we have a supported engine type before attempting to parse it. let element = this._data; if ( (element.localName == MOZSEARCH_LOCALNAME && element.namespaceURI == MOZSEARCH_NS_10) || (element.localName == OPENSEARCH_LOCALNAME && OPENSEARCH_NAMESPACES.includes(element.namespaceURI)) ) { lazy.logConsole.debug("Initing search plugin from", this._location); this._parse(); } else { console.error("Invalid search plugin due to namespace not matching."); throw Components.Exception( this._location + " is not a valid search plugin.", Cr.NS_ERROR_FILE_CORRUPTED ); } // No need to keep a ref to our data (which in some cases can be a document // element) past this point this._data = null; } /** * Extracts data from an OpenSearch URL element and creates an EngineURL * object which is then added to the engine's list of URLs. * * @param {HTMLLinkElement} element * The OpenSearch URL element. * @throws NS_ERROR_FAILURE if a URL object could not be created. * * @see http://opensearch.a9.com/spec/1.1/querysyntax/#urltag. * @see EngineURL() */ _parseURL(element) { var type = element.getAttribute("type"); // According to the spec, method is optional, defaulting to "GET" if not // specified var method = element.getAttribute("method") || "GET"; var template = element.getAttribute("template"); let rels = []; if (element.hasAttribute("rel")) { rels = element.getAttribute("rel").toLowerCase().split(/\s+/); } // Support an alternate suggestion type, see bug 1425827 for details. if (type == "application/json" && rels.includes("suggestions")) { type = lazy.SearchUtils.URL_TYPE.SUGGEST_JSON; } try { var url = new EngineURL(type, method, template); } catch (ex) { throw Components.Exception( "_parseURL: failed to add " + template + " as a URL", Cr.NS_ERROR_FAILURE ); } if (rels.length) { url.rels = rels; } for (var i = 0; i < element.children.length; ++i) { var param = element.children[i]; if (param.localName == "Param") { try { url.addParam(param.getAttribute("name"), param.getAttribute("value")); } catch (ex) { // Ignore failure lazy.logConsole.error("_parseURL: Url element has an invalid param"); } } // Note: MozParams are not supported for OpenSearch engines as they // cannot be app-provided engines. } this._urls.push(url); } /** * Get the icon from an OpenSearch Image element. * * @param {HTMLLinkElement} element * The OpenSearch URL element. * @see http://opensearch.a9.com/spec/1.1/description/#image */ _parseImage(element) { let width = parseInt(element.getAttribute("width"), 10); let height = parseInt(element.getAttribute("height"), 10); let isPrefered = width == 16 && height == 16; if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) { lazy.logConsole.warn( "OpenSearch image element must have positive width and height." ); return; } this._setIcon(element.textContent, isPrefered, width, height); } /** * Extract search engine information from the collected data to initialize * the engine object. */ _parse() { var doc = this._data; for (var i = 0; i < doc.children.length; ++i) { var child = doc.children[i]; switch (child.localName) { case "ShortName": this._name = child.textContent; break; case "Description": this._description = child.textContent; break; case "Url": try { this._parseURL(child); } catch (ex) { // Parsing of the element failed, just skip it. lazy.logConsole.error("Failed to parse URL child:", ex); } break; case "Image": this._parseImage(child); break; case "InputEncoding": // If this is not specified we fallback to the SearchEngine constructor // which currently uses SearchUtils.DEFAULT_QUERY_CHARSET which is // UTF-8 - the same as for OpenSearch. this._queryCharset = child.textContent; break; // Non-OpenSearch elements case "SearchForm": this._searchForm = child.textContent; break; case "UpdateUrl": this._updateURL = child.textContent; break; case "UpdateInterval": this._updateInterval = parseInt(child.textContent); break; case "IconUpdateUrl": this._iconUpdateURL = child.textContent; break; case "ExtensionID": this._extensionID = child.textContent; break; } } if (!this.name || !this._urls.length) { throw Components.Exception( "_parse: No name, or missing URL!", Cr.NS_ERROR_FAILURE ); } if (!this.supportsResponseType(lazy.SearchUtils.URL_TYPE.SEARCH)) { throw Components.Exception( "_parse: No text/html result type!", Cr.NS_ERROR_FAILURE ); } } get _hasUpdates() { // Whether or not the engine has an update URL let selfURL = this._getURLOfType( lazy.SearchUtils.URL_TYPE.OPENSEARCH, "self" ); return !!(this._updateURL || this._iconUpdateURL || selfURL); } /** * Returns the engine's updateURI if it exists and returns null otherwise * * @returns {string?} */ get _updateURI() { let updateURL = this._getURLOfType(lazy.SearchUtils.URL_TYPE.OPENSEARCH); let updateURI = updateURL && updateURL._hasRelation("self") ? updateURL.getSubmission("", this).uri : lazy.SearchUtils.makeURI(this._updateURL); return updateURI; } // This indicates where we found the .xml file to load the engine, // and attempts to hide user-identifiable data (such as username). static getAnonymizedLoadPath(shortName, uri) { return `[${uri.scheme}]${uri.host}/${shortName}.xml`; } }