tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

converter-child.js (12935B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 const lazy = {};
      8 ChromeUtils.defineESModuleGetters(lazy, {
      9  NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
     10 });
     11 
     12 const {
     13  getTheme,
     14  addThemeObserver,
     15  removeThemeObserver,
     16 } = require("resource://devtools/client/shared/theme.js");
     17 
     18 const BinaryInput = Components.Constructor(
     19  "@mozilla.org/binaryinputstream;1",
     20  "nsIBinaryInputStream",
     21  "setInputStream"
     22 );
     23 const BufferStream = Components.Constructor(
     24  "@mozilla.org/io/arraybuffer-input-stream;1",
     25  "nsIArrayBufferInputStream",
     26  "setData"
     27 );
     28 
     29 const kCSP = "default-src 'none'; script-src resource:; img-src 'self';";
     30 
     31 // Localization
     32 loader.lazyGetter(this, "jsonViewStrings", () => {
     33  return Services.strings.createBundle(
     34    "chrome://devtools/locale/jsonview.properties"
     35  );
     36 });
     37 
     38 /**
     39 * This object detects 'application/vnd.mozilla.json.view' content type
     40 * and converts it into a JSON Viewer application that allows simple
     41 * JSON inspection.
     42 *
     43 * Inspired by JSON View: https://github.com/bhollis/jsonview/
     44 */
     45 function Converter() {}
     46 
     47 Converter.prototype = {
     48  QueryInterface: ChromeUtils.generateQI([
     49    "nsIStreamConverter",
     50    "nsIStreamListener",
     51    "nsIRequestObserver",
     52  ]),
     53 
     54  get wrappedJSObject() {
     55    return this;
     56  },
     57 
     58  /**
     59   * This component works as such:
     60   * 1. asyncConvertData captures the listener
     61   * 2. onStartRequest fires, initializes stuff, modifies the listener
     62   *    to match our output type
     63   * 3. onDataAvailable decodes and inserts data into a text node
     64   * 4. onStopRequest flushes data and spits back to the listener
     65   * 5. convert does nothing, it's just the synchronous version
     66   *    of asyncConvertData
     67   */
     68  convert(fromStream) {
     69    return fromStream;
     70  },
     71 
     72  asyncConvertData(fromType, toType, listener) {
     73    this.listener = listener;
     74  },
     75  getConvertedType(_fromType, channel) {
     76    if (channel instanceof Ci.nsIMultiPartChannel) {
     77      throw new Components.Exception(
     78        "JSONViewer doesn't support multipart responses.",
     79        Cr.NS_ERROR_FAILURE
     80      );
     81    }
     82    return "text/html";
     83  },
     84 
     85  onDataAvailable(request, inputStream, offset, count) {
     86    // Decode and insert data.
     87    const buffer = new ArrayBuffer(count);
     88    new BinaryInput(inputStream).readArrayBuffer(count, buffer);
     89    this.decodeAndInsertBuffer(buffer);
     90  },
     91 
     92  onStartRequest(request) {
     93    // Set the content type to HTML in order to parse the doctype, styles
     94    // and scripts. The JSON will be manually inserted as text.
     95    request.QueryInterface(Ci.nsIChannel);
     96    request.contentType = "text/html";
     97 
     98    // Tweak the request's principal in order to allow the related HTML document
     99    // used to display raw JSON to be able to load resource://devtools files
    100    // from the jsonview document.
    101    const uri = lazy.NetUtil.newURI("resource://devtools/client/jsonview/");
    102    const resourcePrincipal =
    103      Services.scriptSecurityManager.createContentPrincipal(
    104        uri,
    105        request.loadInfo.originAttributes
    106      );
    107    request.owner = resourcePrincipal;
    108 
    109    const headers = getHttpHeaders(request);
    110 
    111    // Enforce strict CSP:
    112    try {
    113      request.QueryInterface(Ci.nsIHttpChannel);
    114      request.setResponseHeader("Content-Security-Policy", kCSP, false);
    115      request.setResponseHeader(
    116        "Content-Security-Policy-Report-Only",
    117        "",
    118        false
    119      );
    120    } catch (ex) {
    121      // If this is not an HTTP channel we can't and won't do anything.
    122    }
    123 
    124    // Don't honor the charset parameter and use UTF-8 (see bug 741776).
    125    request.contentCharset = "UTF-8";
    126    this.decoder = new TextDecoder("UTF-8");
    127 
    128    // Changing the content type breaks saving functionality. Fix it.
    129    fixSave(request);
    130 
    131    // Start the request.
    132    this.listener.onStartRequest(request);
    133 
    134    // Initialize stuff.
    135    const win = getWindowForRequest(request);
    136    if (!win || !Components.isSuccessCode(request.status)) {
    137      return;
    138    }
    139 
    140    // We compare actual pointer identities here rather than using .equals(),
    141    // because if things went correctly then the document must have exactly
    142    // the principal we reset it to above. If not, something went wrong.
    143    if (win.document.nodePrincipal != resourcePrincipal) {
    144      // Whatever that document is, it's not ours.
    145      request.cancel(Cr.NS_BINDING_ABORTED);
    146      return;
    147    }
    148 
    149    this.data = exportData(win, headers);
    150    insertJsonData(win, this.data.json);
    151    win.addEventListener("contentMessage", onContentMessage, false, true);
    152    keepThemeUpdated(win);
    153 
    154    // Send the initial HTML code.
    155    const buffer = new TextEncoder().encode(initialHTML(win.document)).buffer;
    156    const stream = new BufferStream(buffer, 0, buffer.byteLength);
    157    this.listener.onDataAvailable(request, stream, 0, stream.available());
    158  },
    159 
    160  onStopRequest(request, statusCode) {
    161    // Flush data if we haven't been canceled.
    162    if (Components.isSuccessCode(statusCode)) {
    163      this.decodeAndInsertBuffer(new ArrayBuffer(0), true);
    164    }
    165 
    166    // Stop the request.
    167    this.listener.onStopRequest(request, statusCode);
    168    this.listener = null;
    169    this.decoder = null;
    170    this.data = null;
    171  },
    172 
    173  // Decodes an ArrayBuffer into a string and inserts it into the page.
    174  decodeAndInsertBuffer(buffer, flush = false) {
    175    // Decode the buffer into a string.
    176    const data = this.decoder.decode(buffer, { stream: !flush });
    177 
    178    // Using `appendData` instead of `textContent +=` is important to avoid
    179    // repainting previous data.
    180    this.data.json.appendData(data);
    181  },
    182 };
    183 
    184 // Lets "save as" save the original JSON, not the viewer.
    185 // To save with the proper extension we need the original content type,
    186 // which has been replaced by application/vnd.mozilla.json.view
    187 function fixSave(request) {
    188  let match;
    189  if (request instanceof Ci.nsIHttpChannel) {
    190    try {
    191      const header = request.getResponseHeader("Content-Type");
    192      match = header.match(/^(application\/(?:[^;]+\+)?json)(?:;|$)/);
    193    } catch (err) {
    194      // Handled below
    195    }
    196  } else {
    197    const uri = request.QueryInterface(Ci.nsIChannel).URI.spec;
    198    match = uri.match(/^data:(application\/(?:[^;,]+\+)?json)[;,]/);
    199  }
    200  let originalType;
    201  if (match) {
    202    originalType = match[1];
    203  } else {
    204    originalType = "application/json";
    205  }
    206  request.QueryInterface(Ci.nsIWritablePropertyBag);
    207  request.setProperty("contentType", originalType);
    208 }
    209 
    210 function getHttpHeaders(request) {
    211  const headers = {
    212    response: [],
    213    request: [],
    214  };
    215  // The request doesn't have to be always nsIHttpChannel
    216  // (e.g. in case of data: URLs)
    217  if (request instanceof Ci.nsIHttpChannel) {
    218    request.visitResponseHeaders({
    219      visitHeader(name, value) {
    220        headers.response.push({ name, value });
    221      },
    222    });
    223    request.visitRequestHeaders({
    224      visitHeader(name, value) {
    225        headers.request.push({ name, value });
    226      },
    227    });
    228  }
    229  return headers;
    230 }
    231 
    232 let jsonViewStringDict = null;
    233 function getAllStrings() {
    234  if (!jsonViewStringDict) {
    235    jsonViewStringDict = {};
    236    for (const string of jsonViewStrings.getSimpleEnumeration()) {
    237      jsonViewStringDict[string.key] = string.value;
    238    }
    239  }
    240  return jsonViewStringDict;
    241 }
    242 
    243 // The two following methods are duplicated from NetworkHelper.sys.mjs
    244 // to avoid pulling the whole NetworkHelper as a dependency during
    245 // initialization.
    246 
    247 /**
    248 * Gets the nsIDOMWindow that is associated with request.
    249 *
    250 * @param nsIHttpChannel request
    251 * @returns nsIDOMWindow or null
    252 */
    253 function getWindowForRequest(request) {
    254  try {
    255    return getRequestLoadContext(request).associatedWindow;
    256  } catch (ex) {
    257    // On some request notificationCallbacks and loadGroup are both null,
    258    // so that we can't retrieve any nsILoadContext interface.
    259    // Fallback on nsILoadInfo to try to retrieve the request's window.
    260    // (this is covered by test_network_get.html and its CSS request)
    261    return request.loadInfo.loadingDocument?.defaultView;
    262  }
    263 }
    264 
    265 /**
    266 * Gets the nsILoadContext that is associated with request.
    267 *
    268 * @param nsIHttpChannel request
    269 * @returns nsILoadContext or null
    270 */
    271 function getRequestLoadContext(request) {
    272  try {
    273    return request.notificationCallbacks.getInterface(Ci.nsILoadContext);
    274  } catch (ex) {
    275    // Ignore.
    276  }
    277 
    278  try {
    279    return request.loadGroup.notificationCallbacks.getInterface(
    280      Ci.nsILoadContext
    281    );
    282  } catch (ex) {
    283    // Ignore.
    284  }
    285 
    286  return null;
    287 }
    288 
    289 // Exports variables that will be accessed by the non-privileged scripts.
    290 function exportData(win, headers) {
    291  const json = new win.Text();
    292  // This pref allows using a deploy preview or local development version of
    293  // the profiler, and also allows tests to avoid hitting the network.
    294  const profilerUrl = Services.prefs.getStringPref(
    295    "devtools.performance.recording.ui-base-url",
    296    "https://profiler.firefox.com"
    297  );
    298  const sizeProfilerEnabled = Services.prefs.getBoolPref(
    299    "devtools.jsonview.size-profiler.enabled",
    300    false
    301  );
    302  const JSONView = Cu.cloneInto(
    303    {
    304      headers,
    305      json,
    306      readyState: "uninitialized",
    307      Locale: getAllStrings(),
    308      profilerUrl,
    309      sizeProfilerEnabled,
    310    },
    311    win,
    312    {
    313      wrapReflectors: true,
    314    }
    315  );
    316  try {
    317    Object.defineProperty(Cu.waiveXrays(win), "JSONView", {
    318      value: JSONView,
    319      configurable: true,
    320      enumerable: true,
    321      writable: true,
    322    });
    323  } catch (error) {
    324    console.error(error);
    325  }
    326  return { json };
    327 }
    328 
    329 // Builds an HTML string that will be used to load stylesheets and scripts.
    330 function initialHTML(doc) {
    331  // Creates an element with the specified type, attributes and children.
    332  function element(type, attributes = {}, children = []) {
    333    const el = doc.createElement(type);
    334    for (const [attr, value] of Object.entries(attributes)) {
    335      el.setAttribute(attr, value);
    336    }
    337    el.append(...children);
    338    return el;
    339  }
    340 
    341  let os;
    342  const platform = Services.appinfo.OS;
    343  if (platform.startsWith("WINNT")) {
    344    os = "win";
    345  } else if (platform.startsWith("Darwin")) {
    346    os = "mac";
    347  } else {
    348    os = "linux";
    349  }
    350 
    351  const baseURI = "resource://devtools/client/jsonview/";
    352 
    353  return (
    354    "<!DOCTYPE html>\n" +
    355    element(
    356      "html",
    357      {
    358        platform: os,
    359        class: "theme-" + getTheme(),
    360        dir: Services.locale.isAppLocaleRTL ? "rtl" : "ltr",
    361      },
    362      [
    363        element("head", {}, [
    364          element("meta", {
    365            "http-equiv": "Content-Security-Policy",
    366            content: kCSP,
    367          }),
    368          element("link", {
    369            rel: "stylesheet",
    370            type: "text/css",
    371            href: "chrome://devtools-jsonview-styles/content/main.css",
    372          }),
    373        ]),
    374        element("body", {}, [
    375          element("div", { id: "content" }, [element("div", { id: "json" })]),
    376          element("script", {
    377            src: baseURI + "json-viewer.mjs",
    378            type: "module",
    379            // This helps ensure that the ES Module get evaluated early,
    380            // even if the HTTP request is transmitted in chunks (browser_jsonview_chunked_json.js).
    381            async: "true",
    382          }),
    383        ]),
    384      ]
    385    ).outerHTML
    386  );
    387 }
    388 
    389 // We insert the received data into a text node, which should be appended into
    390 // the #json element so that the JSON is still displayed even if JS is disabled.
    391 // However, the HTML parser is not synchronous, so this function uses a mutation
    392 // observer to detect the creation of the element. Then the text node is appended.
    393 function insertJsonData(win, json) {
    394  new win.MutationObserver(function (mutations, observer) {
    395    for (const { target, addedNodes } of mutations) {
    396      if (target.nodeType == 1 && target.id == "content") {
    397        for (const node of addedNodes) {
    398          if (node.nodeType == 1 && node.id == "json") {
    399            observer.disconnect();
    400            node.append(json);
    401            return;
    402          }
    403        }
    404      }
    405    }
    406  }).observe(win.document, {
    407    childList: true,
    408    subtree: true,
    409  });
    410 }
    411 
    412 function keepThemeUpdated(win) {
    413  const listener = function () {
    414    win.document.documentElement.className = "theme-" + getTheme();
    415  };
    416  addThemeObserver(listener);
    417  win.addEventListener(
    418    "unload",
    419    function () {
    420      removeThemeObserver(listener);
    421      win = null;
    422    },
    423    { once: true }
    424  );
    425 }
    426 
    427 // Chrome <-> Content communication
    428 function onContentMessage(e) {
    429  // Do not handle events from different documents.
    430  const win = this;
    431  if (win != e.target) {
    432    return;
    433  }
    434 
    435  const value = e.detail.value;
    436  switch (e.detail.type) {
    437    case "save":
    438      win.docShell.messageManager.sendAsyncMessage(
    439        "devtools:jsonview:save",
    440        value
    441      );
    442  }
    443 }
    444 
    445 function createInstance() {
    446  return new Converter();
    447 }
    448 
    449 exports.JsonViewService = {
    450  createInstance,
    451 };