/* 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/. */ "use strict"; const EXPORTED_SYMBOLS = ["FaviconLoader"]; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", }); const STREAM_SEGMENT_SIZE = 4096; const PR_UINT32_MAX = 0xffffffff; const BinaryInputStream = Components.Constructor( "@mozilla.org/binaryinputstream;1", "nsIBinaryInputStream", "setInputStream" ); const StorageStream = Components.Constructor( "@mozilla.org/storagestream;1", "nsIStorageStream", "init" ); const BufferedOutputStream = Components.Constructor( "@mozilla.org/network/buffered-output-stream;1", "nsIBufferedOutputStream", "init" ); const SIZES_TELEMETRY_ENUM = { NO_SIZES: 0, ANY: 1, DIMENSION: 2, INVALID: 3, }; const FAVICON_PARSING_TIMEOUT = 100; const FAVICON_RICH_ICON_MIN_WIDTH = 96; const PREFERRED_WIDTH = 16; // URL schemes that we don't want to load and convert to data URLs. const LOCAL_FAVICON_SCHEMES = ["chrome", "about", "resource", "data"]; const MAX_FAVICON_EXPIRATION = 7 * 24 * 60 * 60 * 1000; const MAX_ICON_SIZE = 2048; const TYPE_ICO = "image/x-icon"; const TYPE_SVG = "image/svg+xml"; function promiseBlobAsDataURL(blob) { return new Promise((resolve, reject) => { let reader = new FileReader(); reader.addEventListener("load", () => resolve(reader.result)); reader.addEventListener("error", reject); reader.readAsDataURL(blob); }); } function promiseBlobAsOctets(blob) { return new Promise((resolve, reject) => { let reader = new FileReader(); reader.addEventListener("load", () => { resolve(Array.from(reader.result).map(c => c.charCodeAt(0))); }); reader.addEventListener("error", reject); reader.readAsBinaryString(blob); }); } function promiseImage(stream, type) { return new Promise((resolve, reject) => { let imgTools = Cc["@mozilla.org/image/tools;1"].getService(Ci.imgITools); imgTools.decodeImageAsync( stream, type, (image, result) => { if (!Components.isSuccessCode(result)) { reject(); return; } resolve(image); }, Services.tm.currentThread ); }); } class FaviconLoad { constructor(iconInfo) { this.icon = iconInfo; let securityFlags; if (iconInfo.node.crossOrigin === "anonymous") { securityFlags = Ci.nsILoadInfo.SEC_REQUIRE_CORS_INHERITS_SEC_CONTEXT; } else if (iconInfo.node.crossOrigin === "use-credentials") { securityFlags = Ci.nsILoadInfo.SEC_REQUIRE_CORS_INHERITS_SEC_CONTEXT | Ci.nsILoadInfo.SEC_COOKIES_INCLUDE; } else { securityFlags = Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT; } this.channel = Services.io.newChannelFromURI( iconInfo.iconUri, iconInfo.node, iconInfo.node.nodePrincipal, iconInfo.node.nodePrincipal, securityFlags | Ci.nsILoadInfo.SEC_ALLOW_CHROME | Ci.nsILoadInfo.SEC_DISALLOW_SCRIPT, Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON ); if (this.channel instanceof Ci.nsIHttpChannel) { this.channel.QueryInterface(Ci.nsIHttpChannel); let referrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance( Ci.nsIReferrerInfo ); // Sometimes node is a document and sometimes it is an element. We need // to set the referrer info correctly either way. if (iconInfo.node.nodeType == iconInfo.node.DOCUMENT_NODE) { referrerInfo.initWithDocument(iconInfo.node); } else { referrerInfo.initWithElement(iconInfo.node); } this.channel.referrerInfo = referrerInfo; } this.channel.loadFlags |= Ci.nsIRequest.LOAD_BACKGROUND | Ci.nsIRequest.VALIDATE_NEVER | Ci.nsIRequest.LOAD_FROM_CACHE; // Sometimes node is a document and sometimes it is an element. This is // the easiest single way to get to the load group in both those cases. this.channel.loadGroup = iconInfo.node.ownerGlobal.document.documentLoadGroup; this.channel.notificationCallbacks = this; if (this.channel instanceof Ci.nsIHttpChannelInternal) { this.channel.blockAuthPrompt = true; } if ( Services.prefs.getBoolPref("network.http.tailing.enabled", true) && this.channel instanceof Ci.nsIClassOfService ) { this.channel.addClassFlags( Ci.nsIClassOfService.Tail | Ci.nsIClassOfService.Throttleable ); } } load() { this._deferred = lazy.PromiseUtils.defer(); // Clear the references when we succeed or fail. let cleanup = () => { this.channel = null; this.dataBuffer = null; this.stream = null; }; this._deferred.promise.then(cleanup, cleanup); this.dataBuffer = new StorageStream(STREAM_SEGMENT_SIZE, PR_UINT32_MAX); // storage streams do not implement writeFrom so wrap it with a buffered stream. this.stream = new BufferedOutputStream( this.dataBuffer.getOutputStream(0), STREAM_SEGMENT_SIZE * 2 ); try { this.channel.asyncOpen(this); } catch (e) { this._deferred.reject(e); } return this._deferred.promise; } cancel() { if (!this.channel) { return; } this.channel.cancel(Cr.NS_BINDING_ABORTED); } onStartRequest(request) {} onDataAvailable(request, inputStream, offset, count) { this.stream.writeFrom(inputStream, count); } asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) { if (oldChannel == this.channel) { this.channel = newChannel; } callback.onRedirectVerifyCallback(Cr.NS_OK); } async onStopRequest(request, statusCode) { if (request != this.channel) { // Indicates that a redirect has occurred. We don't care about the result // of the original channel. return; } this.stream.close(); this.stream = null; if (!Components.isSuccessCode(statusCode)) { if (statusCode == Cr.NS_BINDING_ABORTED) { this._deferred.reject( Components.Exception( `Favicon load from ${this.icon.iconUri.spec} was cancelled.`, statusCode ) ); } else { this._deferred.reject( Components.Exception( `Favicon at "${this.icon.iconUri.spec}" failed to load.`, statusCode ) ); } return; } if (this.channel instanceof Ci.nsIHttpChannel) { if (!this.channel.requestSucceeded) { this._deferred.reject( Components.Exception( `Favicon at "${this.icon.iconUri.spec}" failed to load: ${this.channel.responseStatusText}.`, { data: { httpStatus: this.channel.responseStatus } } ) ); return; } } // By default don't store icons added after "pageshow". let canStoreIcon = this.icon.beforePageShow; if (canStoreIcon) { // Don't store icons responding with Cache-Control: no-store, but always // allow root domain icons. try { if ( this.icon.iconUri.filePath != "/favicon.ico" && this.channel instanceof Ci.nsIHttpChannel && this.channel.isNoStoreResponse() ) { canStoreIcon = false; } } catch (ex) { if (ex.result != Cr.NS_ERROR_NOT_AVAILABLE) { throw ex; } } } // Attempt to get an expiration time from the cache. If this fails, we'll // use this default. let expiration = Date.now() + MAX_FAVICON_EXPIRATION; // This stuff isn't available after onStopRequest returns (so don't start // any async operations before this!). if (this.channel instanceof Ci.nsICacheInfoChannel) { try { expiration = Math.min( this.channel.cacheTokenExpirationTime * 1000, expiration ); } catch (e) { // Ignore failures to get the expiration time. } } try { let stream = new BinaryInputStream(this.dataBuffer.newInputStream(0)); let buffer = new ArrayBuffer(this.dataBuffer.length); stream.readArrayBuffer(buffer.byteLength, buffer); let type = this.channel.contentType; let blob = new Blob([buffer], { type }); if (type != "image/svg+xml") { let octets = await promiseBlobAsOctets(blob); let sniffer = Cc["@mozilla.org/image/loader;1"].createInstance( Ci.nsIContentSniffer ); type = sniffer.getMIMETypeFromContent( this.channel, octets, octets.length ); if (!type) { throw Components.Exception( `Favicon at "${this.icon.iconUri.spec}" did not match a known mimetype.`, Cr.NS_ERROR_FAILURE ); } blob = blob.slice(0, blob.size, type); let image; try { image = await promiseImage(this.dataBuffer.newInputStream(0), type); } catch (e) { throw Components.Exception( `Favicon at "${this.icon.iconUri.spec}" could not be decoded.`, Cr.NS_ERROR_FAILURE ); } if (image.width > MAX_ICON_SIZE || image.height > MAX_ICON_SIZE) { throw Components.Exception( `Favicon at "${this.icon.iconUri.spec}" is too large.`, Cr.NS_ERROR_FAILURE ); } } let dataURL = await promiseBlobAsDataURL(blob); this._deferred.resolve({ expiration, dataURL, canStoreIcon, }); } catch (e) { this._deferred.reject(e); } } getInterface(iid) { if (iid.equals(Ci.nsIChannelEventSink)) { return this; } throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); } } /* * Extract the icon width from the size attribute. It also sends the telemetry * about the size type and size dimension info. * * @param {Array} aSizes An array of strings about size. * @return {Number} A width of the icon in pixel. */ function extractIconSize(aSizes) { let width = -1; let sizesType; const re = /^([1-9][0-9]*)x[1-9][0-9]*$/i; if (aSizes.length) { for (let size of aSizes) { if (size.toLowerCase() == "any") { sizesType = SIZES_TELEMETRY_ENUM.ANY; break; } else { let values = re.exec(size); if (values && values.length > 1) { sizesType = SIZES_TELEMETRY_ENUM.DIMENSION; width = parseInt(values[1]); break; } else { sizesType = SIZES_TELEMETRY_ENUM.INVALID; break; } } } } else { sizesType = SIZES_TELEMETRY_ENUM.NO_SIZES; } // Telemetry probes for measuring the sizes attribute // usage and available dimensions. Services.telemetry .getHistogramById("LINK_ICON_SIZES_ATTR_USAGE") .add(sizesType); if (width > 0) { Services.telemetry .getHistogramById("LINK_ICON_SIZES_ATTR_DIMENSION") .add(width); } return width; } /* * Get link icon URI from a link dom node. * * @param {DOMNode} aLink A link dom node. * @return {nsIURI} A uri of the icon. */ function getLinkIconURI(aLink) { let targetDoc = aLink.ownerDocument; let uri = Services.io.newURI(aLink.href, targetDoc.characterSet); try { uri = uri .mutate() .setUserPass("") .finalize(); } catch (e) { // some URIs are immutable } return uri; } /** * Guess a type for an icon based on its declared type or file extension. */ function guessType(icon) { // No type with no icon if (!icon) { return ""; } // Use the file extension to guess at a type we're interested in if (!icon.type) { let extension = icon.iconUri.filePath.split(".").pop(); switch (extension) { case "ico": return TYPE_ICO; case "svg": return TYPE_SVG; } } // Fuzzily prefer the type or fall back to the declared type return icon.type == "image/vnd.microsoft.icon" ? TYPE_ICO : icon.type || ""; } /* * Selects the best rich icon and tab icon from a list of IconInfo objects. * * @param {Array} iconInfos A list of IconInfo objects. * @param {integer} preferredWidth The preferred width for tab icons. */ function selectIcons(iconInfos, preferredWidth) { if (!iconInfos.length) { return { richIcon: null, tabIcon: null, }; } let preferredIcon; let bestSizedIcon; // Other links with the "icon" tag are the default icons let defaultIcon; // Rich icons are either apple-touch or fluid icons, or the ones of the // dimension 96x96 or greater let largestRichIcon; for (let icon of iconInfos) { if (!icon.isRichIcon) { // First check for svg. If it's not available check for an icon with a // size adapt to the current resolution. If both are not available, prefer // ico files. When multiple icons are in the same set, the latest wins. if (guessType(icon) == TYPE_SVG) { preferredIcon = icon; } else if ( icon.width == preferredWidth && guessType(preferredIcon) != TYPE_SVG ) { preferredIcon = icon; } else if ( guessType(icon) == TYPE_ICO && (!preferredIcon || guessType(preferredIcon) == TYPE_ICO) ) { preferredIcon = icon; } // Check for an icon larger yet closest to preferredWidth, that can be // downscaled efficiently. if ( icon.width >= preferredWidth && (!bestSizedIcon || bestSizedIcon.width >= icon.width) ) { bestSizedIcon = icon; } } // Note that some sites use hi-res icons without specifying them as // apple-touch or fluid icons. if (icon.isRichIcon || icon.width >= FAVICON_RICH_ICON_MIN_WIDTH) { if (!largestRichIcon || largestRichIcon.width < icon.width) { largestRichIcon = icon; } } else { defaultIcon = icon; } } // Now set the favicons for the page in the following order: // 1. Set the best rich icon if any. // 2. Set the preferred one if any, otherwise check if there's a better // sized fit. // This order allows smaller icon frames to eventually override rich icon // frames. let tabIcon = null; if (preferredIcon) { tabIcon = preferredIcon; } else if (bestSizedIcon) { tabIcon = bestSizedIcon; } else if (defaultIcon) { tabIcon = defaultIcon; } return { richIcon: largestRichIcon, tabIcon, }; } class IconLoader { constructor(actor) { this.actor = actor; } async load(iconInfo) { if (this._loader) { this._loader.cancel(); } if (LOCAL_FAVICON_SCHEMES.includes(iconInfo.iconUri.scheme)) { // We need to do a manual security check because the channel won't do // it for us. try { Services.scriptSecurityManager.checkLoadURIWithPrincipal( iconInfo.node.nodePrincipal, iconInfo.iconUri, Services.scriptSecurityManager.ALLOW_CHROME ); } catch (ex) { return; } this.actor.sendAsyncMessage("Link:SetIcon", { pageURL: iconInfo.pageUri.spec, originalURL: iconInfo.iconUri.spec, canUseForTab: !iconInfo.isRichIcon, expiration: undefined, iconURL: iconInfo.iconUri.spec, canStoreIcon: iconInfo.beforePageShow, }); return; } // Let the main process that a tab icon is possibly coming. this.actor.sendAsyncMessage("Link:LoadingIcon", { originalURL: iconInfo.iconUri.spec, canUseForTab: !iconInfo.isRichIcon, }); try { this._loader = new FaviconLoad(iconInfo); let { dataURL, expiration, canStoreIcon } = await this._loader.load(); this.actor.sendAsyncMessage("Link:SetIcon", { pageURL: iconInfo.pageUri.spec, originalURL: iconInfo.iconUri.spec, canUseForTab: !iconInfo.isRichIcon, expiration, iconURL: dataURL, canStoreIcon, }); } catch (e) { if (e.result != Cr.NS_BINDING_ABORTED) { if (typeof e.data?.wrappedJSObject?.httpStatus !== "number") { console.error(e); } // Used mainly for tests currently. this.actor.sendAsyncMessage("Link:SetFailedIcon", { originalURL: iconInfo.iconUri.spec, canUseForTab: !iconInfo.isRichIcon, }); } } finally { this._loader = null; } } cancel() { if (!this._loader) { return; } this._loader.cancel(); this._loader = null; } } class FaviconLoader { constructor(actor) { this.actor = actor; this.iconInfos = []; // Icons added after onPageShow() are likely added by modifying tags // through javascript; we want to avoid storing those permanently because // they are probably used to show badges, and many of them could be // randomly generated. This boolean can be used to track that case. this.beforePageShow = true; // For every page we attempt to find a rich icon and a tab icon. These // objects take care of the load process for each. this.richIconLoader = new IconLoader(actor); this.tabIconLoader = new IconLoader(actor); this.iconTask = new lazy.DeferredTask( () => this.loadIcons(), FAVICON_PARSING_TIMEOUT ); } loadIcons() { // If the page is unloaded immediately after the DeferredTask's timer fires // we can still attempt to load icons, which will fail since the content // window is no longer available. Checking if iconInfos has been cleared // allows us to bail out early in this case. if (!this.iconInfos.length) { return; } let preferredWidth = PREFERRED_WIDTH * Math.ceil(this.actor.contentWindow.devicePixelRatio); let { richIcon, tabIcon } = selectIcons(this.iconInfos, preferredWidth); this.iconInfos = []; if (richIcon) { this.richIconLoader.load(richIcon); } if (tabIcon) { this.tabIconLoader.load(tabIcon); } } addIconFromLink(aLink, aIsRichIcon) { let iconInfo = makeFaviconFromLink(aLink, aIsRichIcon); if (iconInfo) { iconInfo.beforePageShow = this.beforePageShow; this.iconInfos.push(iconInfo); this.iconTask.arm(); return true; } return false; } addDefaultIcon(pageUri) { // Currently ImageDocuments will just load the default favicon, see bug // 403651 for discussion. this.iconInfos.push({ pageUri, iconUri: pageUri .mutate() .setPathQueryRef("/favicon.ico") .finalize(), width: -1, isRichIcon: false, type: TYPE_ICO, node: this.actor.document, beforePageShow: this.beforePageShow, }); this.iconTask.arm(); } onPageShow() { // We're likely done with icon parsing so load the pending icons now. if (this.iconTask.isArmed) { this.iconTask.disarm(); this.loadIcons(); } this.beforePageShow = false; } onPageHide() { this.richIconLoader.cancel(); this.tabIconLoader.cancel(); this.iconTask.disarm(); this.iconInfos = []; } } function makeFaviconFromLink(aLink, aIsRichIcon) { let iconUri = getLinkIconURI(aLink); if (!iconUri) { return null; } // Extract the size type and width. let width = extractIconSize(aLink.sizes); return { pageUri: aLink.ownerDocument.documentURIObject, iconUri, width, isRichIcon: aIsRichIcon, type: aLink.type, node: aLink, }; }