summaryrefslogtreecommitdiffstats
path: root/toolkit/components/search/OpenSearchEngine.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/components/search/OpenSearchEngine.sys.mjs464
1 files changed, 464 insertions, 0 deletions
diff --git a/toolkit/components/search/OpenSearchEngine.sys.mjs b/toolkit/components/search/OpenSearchEngine.sys.mjs
new file mode 100644
index 0000000000..d3d6da576a
--- /dev/null
+++ b/toolkit/components/search/OpenSearchEngine.sys.mjs
@@ -0,0 +1,464 @@
+/* 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`;
+ }
+}