diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/client/jsonview/converter-child.js | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/jsonview/converter-child.js')
-rw-r--r-- | devtools/client/jsonview/converter-child.js | 419 |
1 files changed, 419 insertions, 0 deletions
diff --git a/devtools/client/jsonview/converter-child.js b/devtools/client/jsonview/converter-child.js new file mode 100644 index 0000000000..1e14e8dacb --- /dev/null +++ b/devtools/client/jsonview/converter-child.js @@ -0,0 +1,419 @@ +/* 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 { + getTheme, + addThemeObserver, + removeThemeObserver, +} = require("resource://devtools/client/shared/theme.js"); + +const BinaryInput = Components.Constructor( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); +const BufferStream = Components.Constructor( + "@mozilla.org/io/arraybuffer-input-stream;1", + "nsIArrayBufferInputStream", + "setData" +); + +const kCSP = "default-src 'none' ; script-src resource:; "; + +// Localization +loader.lazyGetter(this, "jsonViewStrings", () => { + return Services.strings.createBundle( + "chrome://devtools/locale/jsonview.properties" + ); +}); + +/** + * This object detects 'application/vnd.mozilla.json.view' content type + * and converts it into a JSON Viewer application that allows simple + * JSON inspection. + * + * Inspired by JSON View: https://github.com/bhollis/jsonview/ + */ +function Converter() {} + +Converter.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsIStreamConverter", + "nsIStreamListener", + "nsIRequestObserver", + ]), + + get wrappedJSObject() { + return this; + }, + + /** + * This component works as such: + * 1. asyncConvertData captures the listener + * 2. onStartRequest fires, initializes stuff, modifies the listener + * to match our output type + * 3. onDataAvailable decodes and inserts data into a text node + * 4. onStopRequest flushes data and spits back to the listener + * 5. convert does nothing, it's just the synchronous version + * of asyncConvertData + */ + convert(fromStream, fromType, toType, ctx) { + return fromStream; + }, + + asyncConvertData(fromType, toType, listener, ctx) { + this.listener = listener; + }, + getConvertedType(fromType, channel) { + return "text/html"; + }, + + onDataAvailable(request, inputStream, offset, count) { + // Decode and insert data. + const buffer = new ArrayBuffer(count); + new BinaryInput(inputStream).readArrayBuffer(count, buffer); + this.decodeAndInsertBuffer(buffer); + }, + + onStartRequest(request) { + // Set the content type to HTML in order to parse the doctype, styles + // and scripts. The JSON will be manually inserted as text. + request.QueryInterface(Ci.nsIChannel); + request.contentType = "text/html"; + + const headers = getHttpHeaders(request); + + // Enforce strict CSP: + try { + request.QueryInterface(Ci.nsIHttpChannel); + request.setResponseHeader("Content-Security-Policy", kCSP, false); + request.setResponseHeader( + "Content-Security-Policy-Report-Only", + "", + false + ); + } catch (ex) { + // If this is not an HTTP channel we can't and won't do anything. + } + + // Don't honor the charset parameter and use UTF-8 (see bug 741776). + request.contentCharset = "UTF-8"; + this.decoder = new TextDecoder("UTF-8"); + + // Changing the content type breaks saving functionality. Fix it. + fixSave(request); + + // Because content might still have a reference to this window, + // force setting it to a null principal to avoid it being same- + // origin with (other) content. + request.loadInfo.resetPrincipalToInheritToNullPrincipal(); + + // Start the request. + this.listener.onStartRequest(request); + + // Initialize stuff. + const win = getWindowForRequest(request); + if (!win || !Components.isSuccessCode(request.status)) { + return; + } + + // We compare actual pointer identities here rather than using .equals(), + // because if things went correctly then the document must have exactly + // the principal we reset it to above. If not, something went wrong. + if (win.document.nodePrincipal != request.loadInfo.principalToInherit) { + // Whatever that document is, it's not ours. + request.cancel(Cr.NS_BINDING_ABORTED); + return; + } + + this.data = exportData(win, headers); + insertJsonData(win, this.data.json); + win.addEventListener("contentMessage", onContentMessage, false, true); + keepThemeUpdated(win); + + // Send the initial HTML code. + const buffer = new TextEncoder().encode(initialHTML(win.document)).buffer; + const stream = new BufferStream(buffer, 0, buffer.byteLength); + this.listener.onDataAvailable(request, stream, 0, stream.available()); + }, + + onStopRequest(request, statusCode) { + // Flush data if we haven't been canceled. + if (Components.isSuccessCode(statusCode)) { + this.decodeAndInsertBuffer(new ArrayBuffer(0), true); + } + + // Stop the request. + this.listener.onStopRequest(request, statusCode); + this.listener = null; + this.decoder = null; + this.data = null; + }, + + // Decodes an ArrayBuffer into a string and inserts it into the page. + decodeAndInsertBuffer(buffer, flush = false) { + // Decode the buffer into a string. + const data = this.decoder.decode(buffer, { stream: !flush }); + + // Using `appendData` instead of `textContent +=` is important to avoid + // repainting previous data. + this.data.json.appendData(data); + }, +}; + +// Lets "save as" save the original JSON, not the viewer. +// To save with the proper extension we need the original content type, +// which has been replaced by application/vnd.mozilla.json.view +function fixSave(request) { + let match; + if (request instanceof Ci.nsIHttpChannel) { + try { + const header = request.getResponseHeader("Content-Type"); + match = header.match(/^(application\/(?:[^;]+\+)?json)(?:;|$)/); + } catch (err) { + // Handled below + } + } else { + const uri = request.QueryInterface(Ci.nsIChannel).URI.spec; + match = uri.match(/^data:(application\/(?:[^;,]+\+)?json)[;,]/); + } + let originalType; + if (match) { + originalType = match[1]; + } else { + originalType = "application/json"; + } + request.QueryInterface(Ci.nsIWritablePropertyBag); + request.setProperty("contentType", originalType); +} + +function getHttpHeaders(request) { + const headers = { + response: [], + request: [], + }; + // The request doesn't have to be always nsIHttpChannel + // (e.g. in case of data: URLs) + if (request instanceof Ci.nsIHttpChannel) { + request.visitResponseHeaders({ + visitHeader(name, value) { + headers.response.push({ name, value }); + }, + }); + request.visitRequestHeaders({ + visitHeader(name, value) { + headers.request.push({ name, value }); + }, + }); + } + return headers; +} + +let jsonViewStringDict = null; +function getAllStrings() { + if (!jsonViewStringDict) { + jsonViewStringDict = {}; + for (const string of jsonViewStrings.getSimpleEnumeration()) { + jsonViewStringDict[string.key] = string.value; + } + } + return jsonViewStringDict; +} + +// The two following methods are duplicated from NetworkHelper.sys.mjs +// to avoid pulling the whole NetworkHelper as a dependency during +// initialization. + +/** + * Gets the nsIDOMWindow that is associated with request. + * + * @param nsIHttpChannel request + * @returns nsIDOMWindow or null + */ +function getWindowForRequest(request) { + try { + return getRequestLoadContext(request).associatedWindow; + } catch (ex) { + // On some request notificationCallbacks and loadGroup are both null, + // so that we can't retrieve any nsILoadContext interface. + // Fallback on nsILoadInfo to try to retrieve the request's window. + // (this is covered by test_network_get.html and its CSS request) + return request.loadInfo.loadingDocument?.defaultView; + } +} + +/** + * Gets the nsILoadContext that is associated with request. + * + * @param nsIHttpChannel request + * @returns nsILoadContext or null + */ +function getRequestLoadContext(request) { + try { + return request.notificationCallbacks.getInterface(Ci.nsILoadContext); + } catch (ex) { + // Ignore. + } + + try { + return request.loadGroup.notificationCallbacks.getInterface( + Ci.nsILoadContext + ); + } catch (ex) { + // Ignore. + } + + return null; +} + +// Exports variables that will be accessed by the non-privileged scripts. +function exportData(win, headers) { + const json = new win.Text(); + const JSONView = Cu.cloneInto( + { + headers, + json, + readyState: "uninitialized", + Locale: getAllStrings(), + }, + win, + { + wrapReflectors: true, + } + ); + try { + Object.defineProperty(Cu.waiveXrays(win), "JSONView", { + value: JSONView, + configurable: true, + enumerable: true, + writable: true, + }); + } catch (error) { + console.error(error); + } + return { json }; +} + +// Builds an HTML string that will be used to load stylesheets and scripts. +function initialHTML(doc) { + // Creates an element with the specified type, attributes and children. + function element(type, attributes = {}, children = []) { + const el = doc.createElement(type); + for (const [attr, value] of Object.entries(attributes)) { + el.setAttribute(attr, value); + } + el.append(...children); + return el; + } + + let os; + const platform = Services.appinfo.OS; + if (platform.startsWith("WINNT")) { + os = "win"; + } else if (platform.startsWith("Darwin")) { + os = "mac"; + } else { + os = "linux"; + } + + const baseURI = "resource://devtools-client-jsonview/"; + + return ( + "<!DOCTYPE html>\n" + + element( + "html", + { + platform: os, + class: "theme-" + getTheme(), + dir: Services.locale.isAppLocaleRTL ? "rtl" : "ltr", + }, + [ + element("head", {}, [ + element("meta", { + "http-equiv": "Content-Security-Policy", + content: kCSP, + }), + element("link", { + rel: "stylesheet", + type: "text/css", + href: "chrome://devtools-jsonview-styles/content/main.css", + }), + ]), + element("body", {}, [ + element("div", { id: "content" }, [element("div", { id: "json" })]), + element("script", { + src: baseURI + "lib/require.js", + "data-main": baseURI + "viewer-config.js", + }), + ]), + ] + ).outerHTML + ); +} + +// We insert the received data into a text node, which should be appended into +// the #json element so that the JSON is still displayed even if JS is disabled. +// However, the HTML parser is not synchronous, so this function uses a mutation +// observer to detect the creation of the element. Then the text node is appended. +function insertJsonData(win, json) { + new win.MutationObserver(function (mutations, observer) { + for (const { target, addedNodes } of mutations) { + if (target.nodeType == 1 && target.id == "content") { + for (const node of addedNodes) { + if (node.nodeType == 1 && node.id == "json") { + observer.disconnect(); + node.append(json); + return; + } + } + } + } + }).observe(win.document, { + childList: true, + subtree: true, + }); +} + +function keepThemeUpdated(win) { + const listener = function () { + win.document.documentElement.className = "theme-" + getTheme(); + }; + addThemeObserver(listener); + win.addEventListener( + "unload", + function (event) { + removeThemeObserver(listener); + win = null; + }, + { once: true } + ); +} + +// Chrome <-> Content communication +function onContentMessage(e) { + // Do not handle events from different documents. + const win = this; + if (win != e.target) { + return; + } + + const value = e.detail.value; + switch (e.detail.type) { + case "save": + win.docShell.messageManager.sendAsyncMessage( + "devtools:jsonview:save", + value + ); + } +} + +function createInstance() { + return new Converter(); +} + +exports.JsonViewService = { + createInstance, +}; |